mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(webview): LinkedIn "Sign in with Google" — keep GSI popup in-app (#1368)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "linkedin",
|
||||
"name": "LinkedIn Messaging",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"serviceURL": "https://www.linkedin.com/messaging/",
|
||||
"icon": "icon.svg"
|
||||
}
|
||||
|
||||
@@ -1,51 +1,317 @@
|
||||
// LinkedIn Messaging recipe. Scrapes the conversation list panel.
|
||||
// LinkedIn Messaging recipe v0.3
|
||||
// Scrapes the conversation list, active thread, and connection requests.
|
||||
// Emits per-conversation-per-day memory ingest events following the
|
||||
// WhatsApp pattern so the recall pipeline gets stable, upsertable docs.
|
||||
(function (api) {
|
||||
if (!api) return;
|
||||
api.log('info', '[linkedin-recipe] starting');
|
||||
api.log('info', '[linkedin-recipe] v0.3 starting');
|
||||
|
||||
let last = '';
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function textOf(el) {
|
||||
return (el && el.textContent ? el.textContent : '').trim();
|
||||
}
|
||||
|
||||
api.loop(function () {
|
||||
const rows = document.querySelectorAll(
|
||||
'li.msg-conversation-listitem, .msg-conversations-container__pillar li'
|
||||
);
|
||||
if (!rows || rows.length === 0) return;
|
||||
function isoDay(ms) {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
// Convert LinkedIn relative timestamps ("2h", "3d", "1w") to epoch ms.
|
||||
function parseRelativeTime(text) {
|
||||
if (!text) return null;
|
||||
var t = text.trim().toLowerCase();
|
||||
var m = t.match(/^(\d+)\s*([smhdw])/);
|
||||
if (!m) return null;
|
||||
var n = parseInt(m[1], 10);
|
||||
var units = { s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 };
|
||||
return Date.now() - n * (units[m[2]] || 0);
|
||||
}
|
||||
|
||||
// Extract a stable conversation ID from a LinkedIn href.
|
||||
// Handles both /messaging/thread/2-xxx/ and /messaging/conversations/2-xxx
|
||||
function chatIdFromHref(href) {
|
||||
if (!href) return null;
|
||||
var m = href.match(/(?:thread|conversation(?:s)?)[/=]([^/?#&]+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// ── conversation list ──────────────────────────────────────────────────────
|
||||
|
||||
var lastListKey = '';
|
||||
var prevUnread = {}; // chatId -> last seen unread count
|
||||
|
||||
function scrapeConversationList() {
|
||||
var rows = document.querySelectorAll([
|
||||
'li.msg-conversation-listitem',
|
||||
'.msg-conversations-container__pillar li',
|
||||
'.scaffold-layout__list-container li[data-id]',
|
||||
'.msg-conversations-container li',
|
||||
].join(', '));
|
||||
if (!rows || rows.length === 0) return null;
|
||||
|
||||
var items = [];
|
||||
rows.forEach(function (row, idx) {
|
||||
const nameEl =
|
||||
// Participant name — multiple fallbacks for selector churn resilience
|
||||
var nameEl =
|
||||
row.querySelector('.msg-conversation-listitem__participant-names') ||
|
||||
row.querySelector('.msg-conversation-card__participant-names') ||
|
||||
row.querySelector('h3');
|
||||
const previewEl =
|
||||
row.querySelector('.msg-conversation-card__title') ||
|
||||
row.querySelector('[data-control-name="overlay.open_conversation"] span') ||
|
||||
row.querySelector('h3') ||
|
||||
row.querySelector('h4');
|
||||
|
||||
// Message snippet / preview
|
||||
var previewEl =
|
||||
row.querySelector('.msg-conversation-card__message-snippet') ||
|
||||
row.querySelector('.msg-conversation-listitem__message-snippet');
|
||||
const unreadEl = row.querySelector('.notification-badge__count, .msg-conversation-card__unread-count');
|
||||
const name = textOf(nameEl);
|
||||
const preview = textOf(previewEl);
|
||||
const unreadNum = parseInt(textOf(unreadEl), 10);
|
||||
const unread = Number.isNaN(unreadNum) ? 0 : unreadNum;
|
||||
row.querySelector('.msg-conversation-listitem__message-snippet') ||
|
||||
row.querySelector('.msg-conversation-card__message-snippet-body') ||
|
||||
row.querySelector('[class*="conversation-card__message"]') ||
|
||||
row.querySelector('[class*="message-snippet"]');
|
||||
|
||||
// Unread badge
|
||||
var unreadEl = row.querySelector([
|
||||
'.notification-badge__count',
|
||||
'.msg-conversation-card__unread-count',
|
||||
'[class*="unread-count"]',
|
||||
'[class*="badge__count"]',
|
||||
].join(', '));
|
||||
|
||||
// Timestamp
|
||||
var timeEl = row.querySelector([
|
||||
'.msg-conversation-card__time-stamp',
|
||||
'.msg-conversation-listitem__time-stamp',
|
||||
'time',
|
||||
'[class*="time-stamp"]',
|
||||
].join(', '));
|
||||
|
||||
// Conversation link → stable chat ID
|
||||
var linkEl = row.querySelector('a[href*="messaging"], a[href*="conversation"]');
|
||||
var href = linkEl ? linkEl.getAttribute('href') : null;
|
||||
var chatId = chatIdFromHref(href);
|
||||
|
||||
var name = textOf(nameEl);
|
||||
var preview = textOf(previewEl);
|
||||
var unreadNum = parseInt(textOf(unreadEl), 10);
|
||||
var unread = Number.isNaN(unreadNum) ? 0 : unreadNum;
|
||||
var timeText = textOf(timeEl);
|
||||
var approxTs = parseRelativeTime(timeText);
|
||||
|
||||
if (name || preview) {
|
||||
messages.push({
|
||||
id: name ? 'li:' + name : 'li:row:' + idx,
|
||||
from: name || null,
|
||||
body: preview || null,
|
||||
items.push({
|
||||
chatId: chatId || ('li:' + (name || idx)),
|
||||
name: name || null,
|
||||
preview: preview || null,
|
||||
unread: unread,
|
||||
timeText: timeText || null,
|
||||
ts: approxTs,
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
const key = JSON.stringify({
|
||||
n: messages.length,
|
||||
first: messages.slice(0, 5).map(function (m) { return m.from + '|' + m.body; }),
|
||||
// ── active thread reading ──────────────────────────────────────────────────
|
||||
|
||||
var lastThreadKey = '';
|
||||
|
||||
function getActiveChatId() {
|
||||
var m = location.href.match(/(?:thread|conversation(?:s)?)[/=]([^/?#&]+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function scrapeActiveThread() {
|
||||
var chatId = getActiveChatId();
|
||||
if (!chatId) return null;
|
||||
|
||||
var events = document.querySelectorAll([
|
||||
'.msg-s-event-listitem',
|
||||
'.msg-s-message-list__event',
|
||||
'[class*="s-event-listitem"]',
|
||||
].join(', '));
|
||||
if (!events || events.length === 0) return null;
|
||||
|
||||
var msgs = [];
|
||||
events.forEach(function (ev) {
|
||||
var bodyEl =
|
||||
ev.querySelector('.msg-s-event-listitem__body') ||
|
||||
ev.querySelector('.msg-s-event-listitem__message-text') ||
|
||||
ev.querySelector('[class*="event-listitem__body"]');
|
||||
var senderEl =
|
||||
ev.querySelector('.msg-s-event-listitem__sender') ||
|
||||
ev.querySelector('.msg-s-event-listitem__author') ||
|
||||
ev.querySelector('[class*="event-listitem__sender"]');
|
||||
var timeEl =
|
||||
ev.querySelector('.msg-s-message-list-content__timestamp') ||
|
||||
ev.querySelector('time') ||
|
||||
ev.querySelector('[class*="timestamp"]');
|
||||
|
||||
var body = textOf(bodyEl);
|
||||
if (!body) return;
|
||||
|
||||
var sender = textOf(senderEl);
|
||||
var timeAttr = timeEl ? (timeEl.getAttribute('datetime') || textOf(timeEl)) : null;
|
||||
var tsMs = timeAttr ? new Date(timeAttr).getTime() : NaN;
|
||||
var tsSec = Number.isNaN(tsMs) ? null : Math.floor(tsMs / 1000);
|
||||
|
||||
// Own messages have a right-aligned or "own-message" CSS marker
|
||||
var fromMe =
|
||||
ev.classList.contains('msg-s-event-listitem--own-message') ||
|
||||
ev.querySelector('[class*="own-message"]') !== null;
|
||||
|
||||
msgs.push({
|
||||
from: sender || null,
|
||||
body: body,
|
||||
timestamp: tsSec,
|
||||
fromMe: fromMe,
|
||||
});
|
||||
});
|
||||
if (key === last) return;
|
||||
last = key;
|
||||
|
||||
api.ingest({ messages: messages, snapshotKey: key });
|
||||
if (msgs.length === 0) return null;
|
||||
return { chatId: chatId, msgs: msgs };
|
||||
}
|
||||
|
||||
// ── connection requests ────────────────────────────────────────────────────
|
||||
|
||||
var lastRequestsKey = '';
|
||||
|
||||
function scrapeConnectionRequests() {
|
||||
if (!location.href.includes('invitation') && !location.href.includes('mynetwork')) return null;
|
||||
var cards = document.querySelectorAll([
|
||||
'.invitation-card',
|
||||
'[data-view-name="manage-received-invitation"]',
|
||||
'[class*="invitation-card"]',
|
||||
].join(', '));
|
||||
if (!cards || cards.length === 0) return null;
|
||||
|
||||
var requests = [];
|
||||
cards.forEach(function (card) {
|
||||
var nameEl = card.querySelector(
|
||||
'.invitation-card__title, h3, [class*="invitation-card__title"]'
|
||||
);
|
||||
var subtitleEl = card.querySelector(
|
||||
'.invitation-card__subtitle, [class*="invitation-card__subtitle"]'
|
||||
);
|
||||
var name = textOf(nameEl);
|
||||
if (!name) return;
|
||||
requests.push({ name: name, subtitle: textOf(subtitleEl) || null });
|
||||
});
|
||||
return requests.length > 0 ? requests : null;
|
||||
}
|
||||
|
||||
// ── main loop ──────────────────────────────────────────────────────────────
|
||||
|
||||
api.loop(function () {
|
||||
var today = isoDay(Date.now());
|
||||
|
||||
// 1. Conversation list
|
||||
var items = scrapeConversationList();
|
||||
if (items && items.length > 0) {
|
||||
// Unread delta check runs on EVERY poll tick, not just when the list
|
||||
// structure changes. listKey only fingerprints name+preview of the first
|
||||
// five rows, so an unread-count bump on row 6+ (or a count-only change)
|
||||
// would never enter the listKey gate and the notification would be missed.
|
||||
items.forEach(function (item) {
|
||||
var prev = prevUnread[item.chatId] || 0;
|
||||
if (item.unread > 0 && item.unread > prev) {
|
||||
api.emit('notify', {
|
||||
title: 'LinkedIn: ' + (item.name || 'New message'),
|
||||
body: item.preview || '',
|
||||
tag: 'linkedin:' + item.chatId,
|
||||
silent: false,
|
||||
});
|
||||
}
|
||||
prevUnread[item.chatId] = item.unread;
|
||||
});
|
||||
|
||||
var listKey = JSON.stringify({
|
||||
n: items.length,
|
||||
first: items.slice(0, 5).map(function (i) { return i.name + '|' + i.preview; }),
|
||||
});
|
||||
|
||||
if (listKey !== lastListKey) {
|
||||
lastListKey = listKey;
|
||||
|
||||
// Redux store snapshot (legacy flat ingest for the accounts pane)
|
||||
api.ingest({
|
||||
messages: items.map(function (i) {
|
||||
return { id: i.chatId, from: i.name, body: i.preview, unread: i.unread };
|
||||
}),
|
||||
snapshotKey: listKey,
|
||||
});
|
||||
|
||||
// Per-conversation-per-day memory ingest (list-level snippet only;
|
||||
// written to :preview key so a richer thread ingest is never overwritten).
|
||||
items.forEach(function (item) {
|
||||
if (!item.preview) return;
|
||||
api.emit('linkedin_conversation', {
|
||||
chatId: item.chatId,
|
||||
chatName: item.name,
|
||||
day: today,
|
||||
messages: [{
|
||||
from: item.name,
|
||||
body: item.preview,
|
||||
timestamp: item.ts ? Math.floor(item.ts / 1000) : null,
|
||||
fromMe: false,
|
||||
}],
|
||||
isSeed: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Active thread — richer per-message ingest when a conversation is open
|
||||
var thread = scrapeActiveThread();
|
||||
if (thread && thread.msgs.length > 0) {
|
||||
var threadKey = JSON.stringify({
|
||||
chatId: thread.chatId,
|
||||
count: thread.msgs.length,
|
||||
last: thread.msgs[thread.msgs.length - 1].body.slice(0, 40),
|
||||
});
|
||||
if (threadKey !== lastThreadKey) {
|
||||
lastThreadKey = threadKey;
|
||||
api.emit('linkedin_conversation', {
|
||||
chatId: thread.chatId,
|
||||
chatName: null, // resolved from list on the service side if available
|
||||
day: today,
|
||||
messages: thread.msgs,
|
||||
isSeed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Connection requests (only fires when on /mynetwork pages)
|
||||
var requests = scrapeConnectionRequests();
|
||||
if (requests) {
|
||||
var requestsKey = JSON.stringify(requests.map(function (r) { return r.name; }));
|
||||
if (requestsKey !== lastRequestsKey) {
|
||||
lastRequestsKey = requestsKey;
|
||||
api.emit('linkedin_requests', { requests: requests });
|
||||
api.log('info', '[linkedin-recipe] connection requests: ' + requests.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── send-message helper (callable via CDP Runtime.evaluate) ───────────────
|
||||
// Usage: window.__linkedinSend("Hello!") → { ok: true } | { ok: false, error: "..." }
|
||||
window.__linkedinSend = function (text) {
|
||||
var input = document.querySelector([
|
||||
'.msg-form__contenteditable',
|
||||
'[data-placeholder*="message"][contenteditable]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
].join(', '));
|
||||
var sendBtn = document.querySelector([
|
||||
'.msg-form__send-btn',
|
||||
'button[type="submit"][class*="send"]',
|
||||
'button[class*="msg-form__send"]',
|
||||
].join(', '));
|
||||
if (!input) return { ok: false, error: 'compose input not found' };
|
||||
if (!sendBtn) return { ok: false, error: 'send button not found' };
|
||||
input.focus();
|
||||
document.execCommand('insertText', false, text);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
setTimeout(function () { sendBtn.click(); }, 100);
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
api.log('info', '[linkedin-recipe] v0.3 ready');
|
||||
})(window.__openhumanRecipe);
|
||||
|
||||
@@ -88,7 +88,17 @@ fn provider_allowed_hosts(provider: &str) -> &'static [&'static str] {
|
||||
match provider {
|
||||
"whatsapp" => &["whatsapp.com", "whatsapp.net", "wa.me"],
|
||||
"telegram" => &["telegram.org", "t.me"],
|
||||
"linkedin" => &["linkedin.com", "licdn.com"],
|
||||
"linkedin" => &[
|
||||
"linkedin.com",
|
||||
"licdn.com",
|
||||
"accounts.google.com",
|
||||
"accounts.googleusercontent.com",
|
||||
"ssl.gstatic.com",
|
||||
"fonts.gstatic.com",
|
||||
"lh3.googleusercontent.com",
|
||||
"oauth2.googleapis.com",
|
||||
"www.googleapis.com",
|
||||
],
|
||||
"slack" => &[
|
||||
"slack.com",
|
||||
"slack-edge.com",
|
||||
@@ -246,6 +256,28 @@ fn popup_should_stay_in_app(provider: &str, url: &Url) -> bool {
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
"linkedin" => {
|
||||
// LinkedIn's "Sign in with Google" button is rendered as a Google
|
||||
// Identity Services (GSI) iframe loaded from
|
||||
// `accounts.google.com/gsi/button`. When the user clicks it, GSI
|
||||
// calls `window.open("https://accounts.google.com/gsi/select?...",
|
||||
// "gsig", "width=500,height=600,...")` to show the account chooser.
|
||||
//
|
||||
// This popup MUST stay as a real in-app child window — NOT routed
|
||||
// to the system browser (blank screen) and NOT a parent navigation
|
||||
// (the parent page would be replaced, so the postMessage credential
|
||||
// callback from the popup can never reach LinkedIn's JS handler).
|
||||
//
|
||||
// After the user selects an account the popup postMessages the
|
||||
// signed credential back to the opener; LinkedIn's GSI callback
|
||||
// receives it and completes sign-in (#1021).
|
||||
match url.host_str() {
|
||||
Some(host) => {
|
||||
is_google_sso_host(host) && url.path().to_ascii_lowercase().contains("gsi")
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -287,7 +319,7 @@ fn is_provider_native_deep_link_scheme(scheme: &str) -> bool {
|
||||
/// `accounts.google.com` popups should be listed. Other providers
|
||||
/// continue to fall through to the default popup-handling path.
|
||||
fn provider_supports_google_sso(provider: &str) -> bool {
|
||||
matches!(provider, "google-meet" | "slack" | "zoom")
|
||||
matches!(provider, "google-meet" | "slack" | "zoom" | "linkedin")
|
||||
}
|
||||
|
||||
/// `true` if a popup request should be denied AND the parent webview
|
||||
@@ -386,6 +418,7 @@ fn is_google_auth_popup(url: &Url) -> bool {
|
||||
|| path.contains("accountchooser")
|
||||
|| path.contains("chooseaccount")
|
||||
|| path.contains("setsid")
|
||||
|| path.contains("oauth2")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -399,7 +432,8 @@ fn is_google_auth_popup(url: &Url) -> bool {
|
||||
|| v.contains("accountchooser")
|
||||
|| v.contains("chooseaccount")
|
||||
|| v.contains("meet.google.com")
|
||||
|| v.contains("mail.google.com"))
|
||||
|| v.contains("mail.google.com")
|
||||
|| v.contains("linkedin.com"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3402,6 +3436,133 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── LinkedIn Google SSO (issue #1021) ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn linkedin_supports_google_sso() {
|
||||
// LinkedIn's "Sign in with Google" button must be handled in-app;
|
||||
// without linkedin in provider_supports_google_sso the popup falls
|
||||
// through to the system browser, which opens blank (#1021).
|
||||
assert!(provider_supports_google_sso("linkedin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkedin_allowed_hosts_cover_google_oauth() {
|
||||
// Google auth chain hops through oauth2.googleapis.com and
|
||||
// www.googleapis.com which are not Google SSO hosts and must be
|
||||
// present in the explicit allowlist so mid-flight redirects don't
|
||||
// leak to the system browser.
|
||||
let hosts = provider_allowed_hosts("linkedin");
|
||||
for host in [
|
||||
"accounts.google.com",
|
||||
"accounts.googleusercontent.com",
|
||||
"ssl.gstatic.com",
|
||||
"fonts.gstatic.com",
|
||||
"lh3.googleusercontent.com",
|
||||
"oauth2.googleapis.com",
|
||||
"www.googleapis.com",
|
||||
] {
|
||||
assert!(hosts.contains(&host), "{host} in LinkedIn allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkedin_google_signin_popup_navigates_parent() {
|
||||
// Clicking "Sign in with Google" on LinkedIn's login page issues a
|
||||
// window.open to accounts.google.com/signin/... — must navigate the
|
||||
// parent in-app instead of opening the system browser (#1021).
|
||||
assert_eq!(
|
||||
popup_should_navigate_parent(
|
||||
"linkedin",
|
||||
&url("https://accounts.google.com/v3/signin/identifier"),
|
||||
)
|
||||
.map(|u| u.to_string()),
|
||||
Some("https://accounts.google.com/v3/signin/identifier".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkedin_google_oauth2_popup_navigates_parent() {
|
||||
// LinkedIn may issue window.open to the initial OAuth2 auth
|
||||
// endpoint (/o/oauth2/v2/auth) which doesn't contain "signin"
|
||||
// in the path — must still be caught and routed in-app (#1021).
|
||||
assert!(popup_should_navigate_parent(
|
||||
"linkedin",
|
||||
&url("https://accounts.google.com/o/oauth2/v2/auth?client_id=x&redirect_uri=https://www.linkedin.com/..."),
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkedin_google_sso_navigation_is_internal() {
|
||||
// Direct (non-popup) navigation to accounts.google.com during the
|
||||
// LinkedIn Google SSO flow must be classified internal so it stays
|
||||
// in the embedded webview.
|
||||
assert!(url_is_internal(
|
||||
"linkedin",
|
||||
&url("https://accounts.google.com/v3/signin/identifier"),
|
||||
));
|
||||
assert!(url_is_internal(
|
||||
"linkedin",
|
||||
&url("https://accounts.youtube.com/accounts/SetSID?..."),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkedin_own_domain_still_internal() {
|
||||
assert!(url_is_internal(
|
||||
"linkedin",
|
||||
&url("https://www.linkedin.com/messaging/"),
|
||||
));
|
||||
assert!(url_is_internal(
|
||||
"linkedin",
|
||||
&url("https://media.licdn.com/dms/image/foo.jpg"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkedin_unrelated_popup_still_goes_to_system_browser() {
|
||||
// Non-Google external links from LinkedIn must still route out.
|
||||
assert!(
|
||||
popup_should_navigate_parent("linkedin", &url("https://example.com/blog"),).is_none()
|
||||
);
|
||||
assert!(!popup_should_stay_in_app(
|
||||
"linkedin",
|
||||
&url("https://example.com/blog"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkedin_gsi_popup_stays_in_app() {
|
||||
// LinkedIn's "Sign in with Google" uses the Google Identity Services
|
||||
// (GSI) library. The GSI button iframe (accounts.google.com/gsi/button)
|
||||
// calls window.open("accounts.google.com/gsi/select?...") to show the
|
||||
// account chooser. This popup must be an in-app child window — NOT sent
|
||||
// to the system browser (blank screen) and NOT a parent navigation (the
|
||||
// postMessage credential callback would have no opener to reach) (#1021).
|
||||
assert!(popup_should_stay_in_app(
|
||||
"linkedin",
|
||||
&url(
|
||||
"https://accounts.google.com/gsi/select?client_id=990339570472-k6nq&ux_mode=popup"
|
||||
),
|
||||
));
|
||||
assert!(popup_should_stay_in_app(
|
||||
"linkedin",
|
||||
&url("https://accounts.google.com/gsi/issue?client_id=x"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkedin_gsi_popup_does_not_navigate_parent() {
|
||||
// The GSI account-chooser popup must NOT navigate the parent — it needs
|
||||
// to remain a child popup for postMessage to work.
|
||||
assert!(popup_should_navigate_parent(
|
||||
"linkedin",
|
||||
&url("https://accounts.google.com/gsi/select?client_id=x"),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_allowed_hosts_include_google_oauth() {
|
||||
let hosts = provider_allowed_hosts("slack");
|
||||
@@ -3750,7 +3911,17 @@ mod tests {
|
||||
// the popup-takeover path. Every other provider (and any unknown
|
||||
// string) must fall through to the default popup handling.
|
||||
assert!(popup_should_navigate_parent(
|
||||
"linkedin",
|
||||
"discord",
|
||||
&url("https://accounts.google.com/signin/v2/identifier"),
|
||||
)
|
||||
.is_none());
|
||||
assert!(popup_should_navigate_parent(
|
||||
"whatsapp",
|
||||
&url("https://accounts.google.com/signin/v2/identifier"),
|
||||
)
|
||||
.is_none());
|
||||
assert!(popup_should_navigate_parent(
|
||||
"unknown-provider",
|
||||
&url("https://accounts.google.com/signin/v2/identifier"),
|
||||
)
|
||||
.is_none());
|
||||
@@ -3862,9 +4033,9 @@ mod tests {
|
||||
assert!(provider_supports_google_sso("google-meet"));
|
||||
assert!(provider_supports_google_sso("slack"));
|
||||
assert!(provider_supports_google_sso("zoom"));
|
||||
assert!(provider_supports_google_sso("linkedin"));
|
||||
assert!(!provider_supports_google_sso("whatsapp"));
|
||||
assert!(!provider_supports_google_sso("telegram"));
|
||||
assert!(!provider_supports_google_sso("linkedin"));
|
||||
assert!(!provider_supports_google_sso("discord"));
|
||||
assert!(!provider_supports_google_sso("browserscan"));
|
||||
assert!(!provider_supports_google_sso(""));
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
import { startWebviewAccountService, stopWebviewAccountService } from '../webviewAccountService';
|
||||
|
||||
// ── Tauri IPC mocks ──────────────────────────────────────────────────────────
|
||||
|
||||
type EventHandler = (evt: { payload: unknown }) => void;
|
||||
const listeners = new Map<string, EventHandler>();
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn().mockResolvedValue(undefined),
|
||||
isTauri: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: vi.fn(async (event: string, handler: EventHandler) => {
|
||||
listeners.set(event, handler);
|
||||
return () => listeners.delete(event);
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Service dep mocks ────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../api/threadApi', () => ({ threadApi: { createNewThread: vi.fn() } }));
|
||||
vi.mock('../chatService', () => ({ chatSend: vi.fn() }));
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn().mockResolvedValue({}) }));
|
||||
vi.mock('../notificationService', () => ({ ingestNotification: vi.fn() }));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACCOUNT_ID = 'acct-linkedin-test';
|
||||
|
||||
async function fireRecipeEvent(payload: {
|
||||
kind: string;
|
||||
account_id?: string;
|
||||
provider?: string;
|
||||
payload: Record<string, unknown>;
|
||||
ts?: number;
|
||||
}): Promise<void> {
|
||||
const handler = listeners.get('webview:event');
|
||||
if (!handler) throw new Error('webview:event listener not attached');
|
||||
handler({
|
||||
payload: { account_id: ACCOUNT_ID, provider: 'linkedin', ts: Date.now(), ...payload },
|
||||
});
|
||||
// Drain microtasks + one macrotask so async persistLinkedInConversation settles.
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('webviewAccountService — LinkedIn recipe events', () => {
|
||||
beforeEach(() => {
|
||||
listeners.clear();
|
||||
vi.clearAllMocks();
|
||||
stopWebviewAccountService();
|
||||
startWebviewAccountService();
|
||||
});
|
||||
|
||||
// ── linkedin_conversation (seed / full thread) ──────────────────────────
|
||||
|
||||
it('calls memory_doc_ingest with canonical key for seed conversations', async () => {
|
||||
await fireRecipeEvent({
|
||||
kind: 'linkedin_conversation',
|
||||
payload: {
|
||||
chatId: 'conv-abc',
|
||||
chatName: 'Alice',
|
||||
day: '2025-05-08',
|
||||
messages: [
|
||||
{ from: 'Alice', body: 'Hello!', timestamp: 1715000000, fromMe: false },
|
||||
{ from: null, body: 'Hi there', timestamp: 1715000060, fromMe: true },
|
||||
],
|
||||
isSeed: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.memory_doc_ingest',
|
||||
params: expect.objectContaining({
|
||||
namespace: `linkedin:${ACCOUNT_ID}`,
|
||||
key: 'conv-abc:2025-05-08', // canonical key — no :preview suffix
|
||||
source_type: 'linkedin-web',
|
||||
tags: expect.arrayContaining(['linkedin', 'chat-transcript', '2025-05-08']),
|
||||
metadata: expect.objectContaining({
|
||||
chat_id: 'conv-abc',
|
||||
chat_name: 'Alice',
|
||||
day: '2025-05-08',
|
||||
is_seed: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses :preview key suffix for non-seed (list snippet) conversations', async () => {
|
||||
await fireRecipeEvent({
|
||||
kind: 'linkedin_conversation',
|
||||
payload: {
|
||||
chatId: 'conv-abc',
|
||||
chatName: 'Alice',
|
||||
day: '2025-05-08',
|
||||
messages: [{ from: 'Alice', body: 'Hey', timestamp: null, fromMe: false }],
|
||||
isSeed: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
key: 'conv-abc:2025-05-08:preview', // :preview suffix prevents overwriting full transcript
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('formats transcript lines with timestamps', async () => {
|
||||
await fireRecipeEvent({
|
||||
kind: 'linkedin_conversation',
|
||||
payload: {
|
||||
chatId: 'conv-xyz',
|
||||
chatName: 'Bob',
|
||||
day: '2025-05-08',
|
||||
messages: [{ from: 'Bob', body: 'Meeting at 3?', timestamp: 1715176800, fromMe: false }],
|
||||
isSeed: true,
|
||||
},
|
||||
});
|
||||
|
||||
const call = vi.mocked(callCoreRpc).mock.calls[0][0] as { params: { content: string } };
|
||||
expect(call.params.content).toContain('Bob: Meeting at 3?');
|
||||
expect(call.params.content).toContain('# LinkedIn — Bob — 2025-05-08');
|
||||
});
|
||||
|
||||
it('omits "--:--" timestamp placeholder when timestamp is null', async () => {
|
||||
await fireRecipeEvent({
|
||||
kind: 'linkedin_conversation',
|
||||
payload: {
|
||||
chatId: 'conv-xyz',
|
||||
chatName: 'Bob',
|
||||
day: '2025-05-08',
|
||||
messages: [{ from: 'Bob', body: 'Hey', timestamp: null, fromMe: false }],
|
||||
isSeed: true,
|
||||
},
|
||||
});
|
||||
|
||||
const call = vi.mocked(callCoreRpc).mock.calls[0][0] as { params: { content: string } };
|
||||
expect(call.params.content).toContain('[--:--] Bob: Hey');
|
||||
});
|
||||
|
||||
it('labels own messages as "me"', async () => {
|
||||
await fireRecipeEvent({
|
||||
kind: 'linkedin_conversation',
|
||||
payload: {
|
||||
chatId: 'conv-xyz',
|
||||
chatName: 'Bob',
|
||||
day: '2025-05-08',
|
||||
messages: [{ from: null, body: 'Sounds good', timestamp: null, fromMe: true }],
|
||||
isSeed: true,
|
||||
},
|
||||
});
|
||||
|
||||
const call = vi.mocked(callCoreRpc).mock.calls[0][0] as { params: { content: string } };
|
||||
expect(call.params.content).toContain('[--:--] me: Sounds good');
|
||||
});
|
||||
|
||||
it('skips RPC call when messages array is empty', async () => {
|
||||
await fireRecipeEvent({
|
||||
kind: 'linkedin_conversation',
|
||||
payload: {
|
||||
chatId: 'conv-abc',
|
||||
chatName: 'Alice',
|
||||
day: '2025-05-08',
|
||||
messages: [],
|
||||
isSeed: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(callCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to chatId as chatName when chatName is null', async () => {
|
||||
await fireRecipeEvent({
|
||||
kind: 'linkedin_conversation',
|
||||
payload: {
|
||||
chatId: 'conv-no-name',
|
||||
chatName: null,
|
||||
day: '2025-05-08',
|
||||
messages: [{ from: 'X', body: 'Hi', timestamp: null, fromMe: false }],
|
||||
isSeed: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ title: 'LinkedIn · conv-no-name · 2025-05-08' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when callCoreRpc rejects', async () => {
|
||||
vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
await expect(
|
||||
fireRecipeEvent({
|
||||
kind: 'linkedin_conversation',
|
||||
payload: {
|
||||
chatId: 'conv-abc',
|
||||
chatName: 'Alice',
|
||||
day: '2025-05-08',
|
||||
messages: [{ from: 'Alice', body: 'Hi', timestamp: null, fromMe: false }],
|
||||
isSeed: true,
|
||||
},
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
// ── linkedin_requests ────────────────────────────────────────────────────
|
||||
|
||||
it('handles linkedin_requests event without throwing', async () => {
|
||||
await expect(
|
||||
fireRecipeEvent({
|
||||
kind: 'linkedin_requests',
|
||||
payload: {
|
||||
requests: [
|
||||
{ name: 'Carol', subtitle: 'Engineer at Acme' },
|
||||
{ name: 'Dave', subtitle: null },
|
||||
],
|
||||
},
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('handles linkedin_requests with empty list without throwing', async () => {
|
||||
await expect(
|
||||
fireRecipeEvent({ kind: 'linkedin_requests', payload: { requests: [] } })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,14 @@ interface IngestPayload {
|
||||
isSeed?: boolean;
|
||||
}
|
||||
|
||||
interface LinkedInConversationPayload {
|
||||
chatId: string;
|
||||
chatName?: string | null;
|
||||
day: string; // YYYY-MM-DD UTC
|
||||
messages: IngestMessage[];
|
||||
isSeed?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationClickPayload {
|
||||
account_id: string;
|
||||
provider: string;
|
||||
@@ -321,6 +329,23 @@ function handleRecipeEvent(evt: RecipeEventPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.kind === 'linkedin_conversation') {
|
||||
void persistLinkedInConversation(
|
||||
accountId,
|
||||
evt.payload as unknown as LinkedInConversationPayload
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.kind === 'linkedin_requests') {
|
||||
const requests = (evt.payload as { requests: Array<{ name: string; subtitle: string | null }> })
|
||||
.requests;
|
||||
if (requests && requests.length > 0) {
|
||||
log('linkedin: %d pending connection request(s) for account=%s', requests.length, accountId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.kind === 'notify') {
|
||||
const payload = evt.payload as RecipeNotifyPayload;
|
||||
const title = String(payload.title ?? '').trim();
|
||||
@@ -477,6 +502,75 @@ async function persistWhatsappChatDay(accountId: string, ingest: IngestPayload):
|
||||
}
|
||||
}
|
||||
|
||||
async function persistLinkedInConversation(
|
||||
accountId: string,
|
||||
payload: LinkedInConversationPayload
|
||||
): Promise<void> {
|
||||
const { chatId, day } = payload;
|
||||
const chatName = payload.chatName ?? chatId;
|
||||
const raw = payload.messages ?? [];
|
||||
if (raw.length === 0) return;
|
||||
|
||||
// Stable namespace. Key is scoped by whether this is a full thread
|
||||
// snapshot (isSeed=true → canonical key) or a list-panel snippet
|
||||
// (isSeed=false → :preview suffix). This prevents a later list-poll
|
||||
// from overwriting a richer thread transcript with a single preview line.
|
||||
const namespace = `linkedin:${accountId}`;
|
||||
const key = payload.isSeed ? `${chatId}:${day}` : `${chatId}:${day}:preview`;
|
||||
|
||||
const sorted = [...raw].sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
|
||||
|
||||
const transcriptLines = sorted.map(m => {
|
||||
const tsSec = m.timestamp ?? 0;
|
||||
const hhmm = tsSec ? new Date(tsSec * 1000).toISOString().slice(11, 16) + 'Z' : '--:--';
|
||||
const who = m.fromMe ? 'me' : (m.from ?? '?');
|
||||
const body = (m.body ?? '').replace(/\r?\n/g, ' ').trim();
|
||||
return `[${hhmm}] ${who}: ${body}`;
|
||||
});
|
||||
|
||||
const header =
|
||||
`# LinkedIn — ${chatName} — ${day}\n` +
|
||||
`chat_id: ${chatId}\n` +
|
||||
`account_id: ${accountId}\n` +
|
||||
`messages: ${sorted.length}\n\n`;
|
||||
const content = header + transcriptLines.join('\n');
|
||||
const title = `LinkedIn · ${chatName} · ${day}`;
|
||||
|
||||
try {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.memory_doc_ingest',
|
||||
params: {
|
||||
namespace,
|
||||
key,
|
||||
title,
|
||||
content,
|
||||
source_type: 'linkedin-web',
|
||||
priority: 'medium',
|
||||
tags: ['linkedin', 'chat-transcript', day],
|
||||
metadata: {
|
||||
provider: 'linkedin',
|
||||
account_id: accountId,
|
||||
chat_id: chatId,
|
||||
chat_name: chatName,
|
||||
day,
|
||||
message_count: sorted.length,
|
||||
is_seed: !!payload.isSeed,
|
||||
},
|
||||
category: 'core',
|
||||
},
|
||||
});
|
||||
log(
|
||||
'linkedin: ingested %d msg(s) into %s key=%s (seed=%s)',
|
||||
sorted.length,
|
||||
namespace,
|
||||
key,
|
||||
!!payload.isSeed
|
||||
);
|
||||
} catch (err) {
|
||||
errLog('linkedin memory write failed %s key=%s: %o', namespace, key, err);
|
||||
}
|
||||
}
|
||||
|
||||
function hashKey(input: string): string {
|
||||
// Simple non-cryptographic hash — just need a stable short key per snapshot.
|
||||
let h = 0;
|
||||
|
||||
Reference in New Issue
Block a user