diff --git a/CHANGELOG.md b/CHANGELOG.md index 06b47d1..f8c0edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [1.0.0.9] - 2026-07-05 + +### Fixed +- `send_message` no longer fails to message a connection with no existing thread (e.g. accepted without a note, so it never shows up in inbox/search) — falls back to the "Compose a new message" typeahead, matching the recipient by name and adding them +- Header/profile-URL verification now handles that new-conversation draft state (`.../messaging/thread/new/`) correctly: its title bar just reads "New message" (not the recipient's name), so verification now checks the conversation's profile card first — it carries the recipient's name and an already-resolved vanity-slug link, so no click-and-navigate is needed there either +- Added `testing/tools/send_message.py`, a manual live tool for sending a real DM via this flow + +## [1.0.0.8] - 2026-07-05 + +### Fixed +- `send_message`'s thread-search fallback now prefers a result matching profile hints (name/header) over blindly taking the first visible search row, only falling back to "first visible" (with a warning) when nothing matches +- After opening a thread, the header name is checked against the expected recipient and the thread's profile link is clicked to confirm its resolved vanity URL matches the target profile — either mismatch now aborts the send instead of silently messaging the wrong person +- Added `testing/tools/check_thread_match.py` and expanded `test_send_message_thread_match.py` covering header and profile-URL matching + ## [1.0.0.7] - 2026-07-05 ### Fixed diff --git a/VERSION b/VERSION index 417ec1b..4f2dfe0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0.7 +1.0.0.9 diff --git a/outreach/browser.py b/outreach/browser.py index fab4c27..c3cc40f 100644 --- a/outreach/browser.py +++ b/outreach/browser.py @@ -2126,7 +2126,9 @@ async def _find_thread_row_in(multi: Locator) -> Locator | None: await self._page.keyboard.press("Enter") await _human_pause(0.8, 1.4) - # Requirement: after searching, open the first visible person/thread result. + # After searching, prefer a result matching profile hints (same check + # as the non-search fallback below); only take the first visible + # result if nothing matches, and warn since that's a guess. search_rows = self._page.locator( "a[href*='/messaging/thread/'], " ".msg-conversation-listitem a, " @@ -2134,14 +2136,21 @@ async def _find_thread_row_in(multi: Locator) -> Locator | None: "li.msg-conversation-listitem, " "[data-view-name*='search'] a[href*='/messaging/']" ) - for i in range(min(await search_rows.count(), 30)): - cand = search_rows.nth(i) - try: - if await cand.is_visible(): - row = cand - break - except Exception: - continue + row = await _find_thread_row_in(search_rows) + if row is None: + for i in range(min(await search_rows.count(), 30)): + cand = search_rows.nth(i) + try: + if await cand.is_visible(): + row = cand + logger.warning( + "No hint match in search results for profile=%s; " + "falling back to first visible result.", + profile_url, + ) + break + except Exception: + continue # Fallback when search UI/results are unavailable: try matching visible threads. if row is None: @@ -2162,11 +2171,266 @@ async def _find_thread_row_in(multi: Locator) -> Locator | None: break if row is None: - logger.warning("No messaging thread matched profile=%s", profile_url) + # No existing thread (e.g. a connection accepted without ever + # exchanging a message never gets a row in search/inbox) — start + # one via the compose typeahead instead. The header/profile-url + # checks below still verify the resulting thread before send. + if not await self._start_new_conversation_via_compose(profile_url, query): + logger.warning("No messaging thread matched profile=%s", profile_url) + return False + else: + await _human_click(self._page, row) + await _human_pause(0.7, 1.2) + + if query and not await self._thread_header_matches(query): + logger.warning( + "Opened thread header does not match expected name '%s' for " + "profile=%s; treating as no match.", + query, + profile_url, + ) + return False + + if not await self._thread_profile_url_matches(profile_url): + return False + + return True + + async def _start_new_conversation_via_compose( + self, profile_url: str, query: str + ) -> bool: + """ + Fallback for when inbox search finds no thread at all — e.g. a + connection accepted without a note never gets a conversation row + until someone messages them first. Uses the "Compose a new message" + button and its recipient typeahead instead. + + Only selects a recipient; the caller's existing header/profile-url + checks still verify the resulting thread before anything is sent. + """ + if not query: + logger.info("_start_new_conversation_via_compose: no query name to search for.") + return False + + compose_btn = self._page.locator( + "button.msg-conversations-container__compose-btn, " + "button[aria-label*='Compose' i]" + ).first + if not await compose_btn.count(): + logger.info("_start_new_conversation_via_compose: compose button not found.") + return False + await _human_click(self._page, compose_btn) + await _human_pause(0.4, 0.8) + + recipient_input = self._page.locator( + ".msg-connections-typeahead__search-field, " + "input[placeholder*='Type a name' i]" + ).first + try: + await expect(recipient_input).to_be_visible(timeout=EL_TIMEOUT) + except Exception: + logger.info( + "_start_new_conversation_via_compose: recipient field never appeared." + ) + return False + + await _human_click(self._page, recipient_input) + for ch in query: + await self._page.keyboard.type(ch) + await asyncio.sleep(random.uniform(0.04, 0.14)) + await _human_pause(0.6, 1.1) + + options = self._page.locator("li.msg-connections-typeahead__search-result") + match: Locator | None = None + for i in range(min(await options.count(), 20)): + opt = options.nth(i) + try: + if not await opt.is_visible(): + continue + name_text = ( + ( + await opt.locator( + ".msg-connections-typeahead__entity-description-list dt" + ).inner_text() + ) + or "" + ).strip().lower() + except Exception: + continue + name = name_text.split("•")[0].strip() + if query.lower() in name or name in query.lower(): + match = opt + break + + if match is None: + for i in range(min(await options.count(), 20)): + opt = options.nth(i) + try: + if await opt.is_visible(): + match = opt + logger.warning( + "No name match in compose typeahead for profile=%s; " + "falling back to first visible result.", + profile_url, + ) + break + except Exception: + continue + + if match is None: + logger.info( + "_start_new_conversation_via_compose: no candidates for profile=%s", + profile_url, + ) return False - await _human_click(self._page, row) - await _human_pause(0.7, 1.2) + await _human_click(self._page, match) + await _human_pause(0.5, 0.9) + return True + + async def _thread_header_matches(self, query: str) -> bool: + """ + Confirm the just-opened conversation's name matches ``query`` (the + search name/profile-derived name), as a final check against clicking + into the wrong thread. Returns True if the header can't be read at + all (selector drift), since that's not a wrong-thread signal. + + A conversation just started via compose has no persisted thread id + yet (URL is ``.../messaging/thread/new/``) — its title bar just says + "New message", so the profile-card name is checked first and takes + priority over that generic, uninformative title. + """ + header_candidates = [ + self._page.locator( + ".msg-s-profile-card .profile-card-one-to-one__profile-link" + ).first, + self._page.locator( + ".msg-thread__link-to-profile .msg-entity-lockup__entity-title" + ).first, + self._page.locator(".msg-title-bar__title-bar-title h2").first, + ] + header = None + for cand in header_candidates: + try: + if await cand.count(): + header = cand + break + except Exception: + continue + if header is None: + logger.info("_thread_header_matches: header selector found nothing; skipping.") + return True + try: + name = ((await header.inner_text()) or "").strip().lower() + except Exception as exc: + logger.info("_thread_header_matches: failed to read header (%s); skipping.", exc) + return True + if not name: + logger.info("_thread_header_matches: header text was empty; skipping.") + return True + q = query.lower() + return q in name or name in q + + async def _thread_profile_url_matches(self, profile_url: str) -> bool: + """ + Open the thread's profile link (a permanent ``/in/ACoAA…`` member ID, + not the vanity slug) exactly like a real user would — click it in + place — and read where LinkedIn's own client-side router lands, + then navigate back to the thread. + + A direct fetch/navigation to that link doesn't show the vanity URL: + LinkedIn's server serves the ACoAA-member-ID URL as-is, and the + redirect to the vanity slug only happens via client-side routing + triggered by an in-app click. Name matching alone can't catch + same-name mismatches; the resolved profile URL is the ground truth. + Returns True if the link can't be read, or the redirect doesn't + resolve to a vanity URL in time, since that's not a wrong-thread + signal — just leaves us unable to verify. + """ + expected = self._canonical_in_profile_url(profile_url).lower() + + # A conversation just started via compose (URL .../messaging/thread/new/) + # renders its own profile card with a direct vanity-slug link already + # resolved — no click-and-observe-navigation needed for that case. + card_link = self._page.locator( + ".msg-s-profile-card .profile-card-one-to-one__profile-link" + ).first + if await card_link.count(): + try: + href = (await card_link.get_attribute("href") or "").strip() + except Exception as exc: + logger.info( + "_thread_profile_url_matches: failed to read profile-card link (%s); skipping.", + exc, + ) + href = "" + if href: + resolved = self._canonical_in_profile_url(href).lower() + if resolved != expected: + logger.warning( + "New-conversation profile card link (%s) resolved to %s, " + "expected %s; treating as no match.", + href, + resolved, + expected, + ) + return False + return True + + link = self._page.locator(".msg-thread__link-to-profile").first + try: + if not await link.count(): + logger.info( + "_thread_profile_url_matches: profile-link selector found nothing; skipping." + ) + return True + href = (await link.get_attribute("href") or "").strip() + except Exception as exc: + logger.info("_thread_profile_url_matches: failed to read profile link (%s); skipping.", exc) + return True + if not href: + logger.info("_thread_profile_url_matches: profile-link href was empty; skipping.") + return True + + resolved_raw = "" + try: + await _human_click(self._page, link) + await _human_pause(0.8, 1.5) + try: + await self._page.wait_for_url( + lambda url: "/in/" in url.lower() and "acoaa" not in url.lower(), + timeout=10_000, + ) + except Exception: + pass + resolved_raw = self._page.url + except Exception as exc: + logger.info("_thread_profile_url_matches: clicking %s failed (%s); skipping.", href, exc) + finally: + try: + await self._page.go_back(timeout=NAV_TIMEOUT, wait_until="domcontentloaded") + await _human_pause(0.7, 1.2) + except Exception as exc: + logger.info("_thread_profile_url_matches: navigating back failed (%s).", exc) + + if not resolved_raw or "acoaa" in resolved_raw.lower(): + logger.info( + "_thread_profile_url_matches: %s never resolved to a vanity URL (got %s); skipping.", + href, + resolved_raw, + ) + return True + + resolved = self._canonical_in_profile_url(resolved_raw).lower() + if resolved != expected: + logger.warning( + "Opened thread's profile link (%s) resolved to %s, expected %s; " + "treating as no match.", + href, + resolved, + expected, + ) + return False return True async def send_message( diff --git a/pyproject.toml b/pyproject.toml index c140007..896e795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ebase" -version = "1.0.0.7" +version = "1.0.0.9" description = "LinkedIn recruiting outreach that runs inside Claude Code" readme = "README.md" license = "MIT" diff --git a/testing/tests/test_send_message_thread_match.py b/testing/tests/test_send_message_thread_match.py new file mode 100644 index 0000000..0fd7b4f --- /dev/null +++ b/testing/tests/test_send_message_thread_match.py @@ -0,0 +1,374 @@ +"""Unit tests for the issue #13 fix: search results must be hint-matched, +not just the first visible row, before send_message/fetch_chat_history act +on a thread.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from outreach.browser import LinkedInBrowser + +SEARCH_ROWS_SELECTOR = ( + "a[href*='/messaging/thread/'], " + ".msg-conversation-listitem a, " + ".msg-conversation-listitem div.msg-conversation-listitem__link, " + "li.msg-conversation-listitem, " + "[data-view-name*='search'] a[href*='/messaging/']" +) + +COMPOSE_BTN_SELECTOR = ( + "button.msg-conversations-container__compose-btn, " + "button[aria-label*='Compose' i]" +) +RECIPIENT_INPUT_SELECTOR = ( + ".msg-connections-typeahead__search-field, " + "input[placeholder*='Type a name' i]" +) +COMPOSE_OPTIONS_SELECTOR = "li.msg-connections-typeahead__search-result" + + +def _make_compose_option(name_text: str, *, visible: bool = True) -> MagicMock: + opt = MagicMock() + opt.is_visible = AsyncMock(return_value=visible) + dt = MagicMock() + dt.inner_text = AsyncMock(return_value=name_text) + opt.locator = MagicMock(return_value=dt) + return opt + + +def _make_row(*, href: str = "", text: str = "", visible: bool = True) -> MagicMock: + row = MagicMock() + row.is_visible = AsyncMock(return_value=visible) + row.get_attribute = AsyncMock(return_value=href) + row.inner_text = AsyncMock(return_value=text) + return row + + +THREAD_LINK_HEADER_SELECTOR = ".msg-thread__link-to-profile .msg-entity-lockup__entity-title" +TITLE_BAR_HEADER_SELECTOR = ".msg-title-bar__title-bar-title h2" +PROFILE_CARD_LINK_SELECTOR = ".msg-s-profile-card .profile-card-one-to-one__profile-link" + + +def _make_page( + rows: list[MagicMock], + *, + header_name: str | None = None, + profile_link_href: str | None = None, + compose_available: bool = False, + compose_options: list[MagicMock] | None = None, + profile_card_href: str | None = None, +) -> MagicMock: + page = MagicMock() + page.url = "https://www.linkedin.com/messaging/" + page.wait_for_selector = AsyncMock() + page.wait_for_url = AsyncMock() + page.go_back = AsyncMock() + page.keyboard = MagicMock(type=AsyncMock(), press=AsyncMock()) + + search_box = MagicMock() + search_box.count = AsyncMock(return_value=1) + search_box.is_visible = AsyncMock(return_value=True) + search_box.fill = AsyncMock() + + rows_locator = MagicMock() + rows_locator.count = AsyncMock(return_value=len(rows)) + rows_locator.nth = MagicMock(side_effect=lambda i: rows[i]) + + header = MagicMock() + header.count = AsyncMock(return_value=1 if header_name is not None else 0) + header.inner_text = AsyncMock(return_value=header_name or "") + + profile_link = MagicMock() + profile_link.count = AsyncMock(return_value=1 if profile_link_href is not None else 0) + profile_link.get_attribute = AsyncMock(return_value=profile_link_href or "") + page.profile_link = profile_link # test-only handle, see _clicking_resolves_to() + + compose_btn = MagicMock() + compose_btn.count = AsyncMock(return_value=1 if compose_available else 0) + page.compose_btn = compose_btn # test-only handle, for click assertions + + recipient_input = MagicMock() + page.recipient_input = recipient_input + + options = compose_options or [] + options_locator = MagicMock() + options_locator.count = AsyncMock(return_value=len(options)) + options_locator.nth = MagicMock(side_effect=lambda i: options[i]) + + profile_card_link = MagicMock() + profile_card_link.count = AsyncMock(return_value=1 if profile_card_href is not None else 0) + profile_card_link.get_attribute = AsyncMock(return_value=profile_card_href or "") + profile_card_link.inner_text = AsyncMock(return_value=header_name or "") + + def locator_side_effect(sel: str): + if sel == SEARCH_ROWS_SELECTOR: + return rows_locator + if sel == "input[placeholder*='Search messages' i]": + m = MagicMock() + m.first = search_box + return m + if sel == PROFILE_CARD_LINK_SELECTOR: + return MagicMock(first=profile_card_link) + if sel == THREAD_LINK_HEADER_SELECTOR: + return MagicMock(first=header) + if sel == TITLE_BAR_HEADER_SELECTOR: + return MagicMock(first=MagicMock(count=AsyncMock(return_value=0))) + if sel == ".msg-thread__link-to-profile": + return MagicMock(first=profile_link) + if sel == COMPOSE_BTN_SELECTOR: + return MagicMock(first=compose_btn) + if sel == RECIPIENT_INPUT_SELECTOR: + return MagicMock(first=recipient_input) + if sel == COMPOSE_OPTIONS_SELECTOR: + return options_locator + m = MagicMock() + m.count = AsyncMock(return_value=0) + m.first = MagicMock() + m.first.count = AsyncMock(return_value=0) + return m + + page.locator = MagicMock(side_effect=locator_side_effect) + page.get_by_role = MagicMock( + return_value=MagicMock(first=MagicMock(count=AsyncMock(return_value=0))) + ) + return page + + +def _browser(page: MagicMock) -> LinkedInBrowser: + li = object.__new__(LinkedInBrowser) + li._page = page + li._ctx = MagicMock() + return li + + +def _clicking_resolves_to(page: MagicMock, resolved_url: str): + """Simulate LinkedIn's client-side router: clicking the profile link + changes ``page.url`` to the vanity URL, like a real click would.""" + + async def side_effect(clicked_page, target): + if target is page.profile_link: + clicked_page.url = resolved_url + + return side_effect + + +@pytest.mark.asyncio +async def test_search_results_prefer_hint_match_over_first_visible(): + wrong_first = _make_row(text="Unrelated Person, 2nd") + correct_match = _make_row(text="Jay Sato, 1st") + li = _browser(_make_page([wrong_first, correct_match])) + + with ( + patch("outreach.browser._human_click", new=AsyncMock()) as click, + patch("outreach.browser._human_pause", new=AsyncMock()), + ): + ok = await li._open_message_ui_from_messaging( + "https://www.linkedin.com/in/jay-sato-263a85270/" + ) + + assert ok + click.assert_awaited_with(li._page, correct_match) + + +@pytest.mark.asyncio +async def test_search_results_fall_back_to_first_visible_with_warning(): + first = _make_row(text="No relation here") + second = _make_row(text="Also unrelated") + li = _browser(_make_page([first, second])) + + with ( + patch("outreach.browser._human_click", new=AsyncMock()) as click, + patch("outreach.browser._human_pause", new=AsyncMock()), + patch("outreach.browser.logger") as logger, + ): + ok = await li._open_message_ui_from_messaging( + "https://www.linkedin.com/in/jay-sato-263a85270/" + ) + + assert ok + click.assert_awaited_with(li._page, first) + assert logger.warning.called + + +@pytest.mark.asyncio +async def test_thread_header_mismatch_rejects_wrong_thread(): + """Row text matched, but the opened thread's own title bar says otherwise + (e.g. a stale/misleading list row) — must not be treated as success.""" + row = _make_row(text="Jay Sato, 1st") + li = _browser(_make_page([row], header_name="Andrew Barreto")) + + with ( + patch("outreach.browser._human_click", new=AsyncMock()), + patch("outreach.browser._human_pause", new=AsyncMock()), + patch("outreach.browser.logger") as logger, + ): + ok = await li._open_message_ui_from_messaging( + "https://www.linkedin.com/in/jay-sato-263a85270/" + ) + + assert not ok + assert logger.warning.called + + +@pytest.mark.asyncio +async def test_thread_header_match_confirms_open(): + row = _make_row(text="Jay Sato, 1st") + li = _browser(_make_page([row], header_name="Jay Sato")) + + with ( + patch("outreach.browser._human_click", new=AsyncMock()), + patch("outreach.browser._human_pause", new=AsyncMock()), + ): + ok = await li._open_message_ui_from_messaging( + "https://www.linkedin.com/in/jay-sato-263a85270/" + ) + + assert ok + + +@pytest.mark.asyncio +async def test_thread_profile_url_mismatch_rejects_even_with_matching_name(): + """Same name, but clicking the thread's profile link lands on a + different vanity URL — that's the ground truth over the (possibly + duplicate) displayed name.""" + row = _make_row(text="Jay Sato, 1st") + page = _make_page( + [row], + header_name="Jay Sato", + profile_link_href="https://www.linkedin.com/in/ACoAAdifferentperson", + ) + li = _browser(page) + + with ( + patch( + "outreach.browser._human_click", + new=AsyncMock( + side_effect=_clicking_resolves_to( + page, "https://www.linkedin.com/in/a-different-jay-sato/" + ) + ), + ), + patch("outreach.browser._human_pause", new=AsyncMock()), + patch("outreach.browser.logger") as logger, + ): + ok = await li._open_message_ui_from_messaging( + "https://www.linkedin.com/in/jay-sato-263a85270/" + ) + + assert not ok + assert logger.warning.called + + +@pytest.mark.asyncio +async def test_thread_profile_url_match_confirms_open(): + row = _make_row(text="Jay Sato, 1st") + page = _make_page( + [row], + header_name="Jay Sato", + profile_link_href="https://www.linkedin.com/in/ACoAAjaysato", + ) + li = _browser(page) + + with ( + patch( + "outreach.browser._human_click", + new=AsyncMock( + side_effect=_clicking_resolves_to( + page, "https://www.linkedin.com/in/jay-sato-263a85270/" + ) + ), + ), + patch("outreach.browser._human_pause", new=AsyncMock()), + ): + ok = await li._open_message_ui_from_messaging( + "https://www.linkedin.com/in/jay-sato-263a85270/" + ) + + assert ok + + +@pytest.mark.asyncio +async def test_no_thread_falls_back_to_compose_and_matches_recipient(): + """A connection accepted without a note has no thread anywhere in the + inbox — must fall back to 'Compose a new message' and its typeahead. + + The resulting draft (.../messaging/thread/new/) has no persisted thread + id, so its title bar just says "New message" — verification must use the + profile card's direct vanity-slug link instead (no click-resolve needed).""" + match = _make_compose_option("Jay Sato • 1st") + page = _make_page( + [], + header_name="Jay Sato", + profile_card_href="https://www.linkedin.com/in/jay-sato-263a85270/", + compose_available=True, + compose_options=[match], + ) + li = _browser(page) + + with ( + patch("outreach.browser._human_click", new=AsyncMock()) as click, + patch("outreach.browser._human_pause", new=AsyncMock()), + patch( + "outreach.browser.expect", + new=MagicMock(return_value=MagicMock(to_be_visible=AsyncMock())), + ), + ): + ok = await li._open_message_ui_from_messaging( + "https://www.linkedin.com/in/jay-sato-263a85270/" + ) + + assert ok + click.assert_any_await(li._page, page.compose_btn) + click.assert_any_await(li._page, match) + + +@pytest.mark.asyncio +async def test_compose_fallback_wrong_candidate_caught_by_profile_check(): + """Typeahead has two same-name candidates (a real LinkedIn scenario); + the name-only match can pick the wrong one, but the profile card's + resolved vanity URL must still catch it before send.""" + wrong_match = _make_compose_option("Andrew Barreto • 2nd") + page = _make_page( + [], + header_name="Andrew Barreto", + profile_card_href="https://www.linkedin.com/in/a-different-andrew-barreto/", + compose_available=True, + compose_options=[wrong_match], + ) + li = _browser(page) + + with ( + patch("outreach.browser._human_click", new=AsyncMock()), + patch("outreach.browser._human_pause", new=AsyncMock()), + patch( + "outreach.browser.expect", + new=MagicMock(return_value=MagicMock(to_be_visible=AsyncMock())), + ), + patch("outreach.browser.logger") as logger, + ): + ok = await li._open_message_ui_from_messaging( + "https://www.linkedin.com/in/andrew-barreto-123abc/" + ) + + assert not ok + assert logger.warning.called + + +@pytest.mark.asyncio +async def test_no_thread_and_no_compose_button_returns_false(): + page = _make_page([], compose_available=False) + li = _browser(page) + + with ( + patch("outreach.browser._human_click", new=AsyncMock()), + patch("outreach.browser._human_pause", new=AsyncMock()), + patch("outreach.browser.logger") as logger, + ): + ok = await li._open_message_ui_from_messaging( + "https://www.linkedin.com/in/jay-sato-263a85270/" + ) + + assert not ok + assert logger.warning.called diff --git a/testing/tools/check_thread_match.py b/testing/tools/check_thread_match.py new file mode 100644 index 0000000..568b673 --- /dev/null +++ b/testing/tools/check_thread_match.py @@ -0,0 +1,45 @@ +""" +Manual live check for issue #13: opens the messaging thread `send_message` +would pick for a profile, without sending anything, so you can eyeball +whether it landed on the right conversation. + +Requires a real Chrome with CDP debugging and an active LinkedIn login: + make browser # or: chrome --remote-debugging-port=9222 + +Usage: + uv run testing/tools/check_thread_match.py [--search-name "Jay Sato"] +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) # repo root + +from outreach.browser import LinkedInBrowser + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + + +async def main(profile_url: str, search_name: str | None, cdp_url: str) -> None: + async with LinkedInBrowser(mode="attach", cdp_url=cdp_url) as li: + opened = await li._open_message_ui_from_messaging( + profile_url, search_name=search_name + ) + if not opened: + print("No matching thread found.") + return + print("Check the browser window — is this the right person?") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("profile_url", help="LinkedIn profile URL of the intended recipient") + parser.add_argument("--search-name", default=None, help="Name to type into inbox search") + parser.add_argument("--cdp-url", default="http://localhost:9222") + args = parser.parse_args() + asyncio.run(main(args.profile_url, args.search_name, args.cdp_url)) diff --git a/testing/tools/send_message.py b/testing/tools/send_message.py new file mode 100644 index 0000000..01674b0 --- /dev/null +++ b/testing/tools/send_message.py @@ -0,0 +1,41 @@ +""" +Manual live tool: sends a real DM to an existing 1st-degree connection via +LinkedInBrowser.send_message (same thread-match + verification logic used +in production, see check_thread_match.py for a no-send dry run of that). + +Requires a real Chrome with CDP debugging and an active LinkedIn login: + make browser # or: chrome --remote-debugging-port=9222 + +Usage: + uv run testing/tools/send_message.py "" [--search-name "Jay Sato"] +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) # repo root + +from outreach.browser import LinkedInBrowser + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + + +async def main(profile_url: str, message: str, search_name: str | None, cdp_url: str) -> None: + async with LinkedInBrowser(mode="attach", cdp_url=cdp_url) as li: + sent = await li.send_message(profile_url, message, search_name=search_name) + print("Sent." if sent else "Failed to send — see warnings above.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("profile_url", help="LinkedIn profile URL of the recipient") + parser.add_argument("message", help="Message text to send") + parser.add_argument("--search-name", default=None, help="Name to type into inbox search") + parser.add_argument("--cdp-url", default="http://localhost:9222") + args = parser.parse_args() + asyncio.run(main(args.profile_url, args.message, args.search_name, args.cdp_url)) diff --git a/uv.lock b/uv.lock index 82dad76..0cf0a0d 100644 --- a/uv.lock +++ b/uv.lock @@ -245,7 +245,7 @@ wheels = [ [[package]] name = "ebase" -version = "1.0.0.7" +version = "1.0.0.9" source = { virtual = "." } dependencies = [ { name = "certifi" },