Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0.7
1.0.0.9
288 changes: 276 additions & 12 deletions outreach/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2126,22 +2126,31 @@ 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, "
".msg-conversation-listitem div.msg-conversation-listitem__link, "
"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:
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading