fix(webview): LinkedIn "Sign in with Google" — keep GSI popup in-app (#1368)

This commit is contained in:
YellowSnnowmann
2026-05-08 19:03:02 -07:00
committed by GitHub
parent 44417543dd
commit 9c1df8d91b
5 changed files with 803 additions and 34 deletions
+1 -1
View File
@@ -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"
}
+294 -28
View File
@@ -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);
+176 -5
View File
@@ -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();
});
});
+94
View File
@@ -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;