fix: send_message fails with message_unavailable on modern LinkedIn UI#383
fix: send_message fails with message_unavailable on modern LinkedIn UI#383IT-AlIan wants to merge 1 commit intostickerdaniel:mainfrom
Conversation
LinkedIn's current UI renders the Message button as a <button> that opens a floating chat overlay via JavaScript, not as an <a href="/messaging/compose/"> anchor. The old code found no anchor and returned message_unavailable immediately. Three issues fixed: 1. _resolve_message_compose_href: add fallback that finds the Message button by aria-label or visible text (multilingual), then extracts the profile URN from data attributes or inline scripts to build the compose URL directly. 2. _click_profile_message_button (new method): clicks the Message button directly when no compose URL can be derived, triggering the overlay natively. 3. send_message: when _resolve_message_compose_href returns None, call _click_profile_message_button and wait up to 8s for the compose textbox via wait_for_selector instead of bailing out immediately. 4. _MESSAGING_COMPOSE_FALLBACK_SELECTORS: add selectors that work outside <main> (the overlay is a floating widget) and cover localised aria-label values (messaggio, scrivi). Tested on a real 1st-degree connection with LinkedIn UI in Italian. send_message now returns status: "sent". Implemented with Claude Code (claude-sonnet-4-6) by Anthropic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR fixes
Confidence Score: 4/5Core fix is sound but two localization logic bugs mean German/Spanish LinkedIn UIs may still fail. The primary fix (overlay detection, URN extraction, async wait) is well-structured and verified on Italian LinkedIn. However, the linkedin_mcp_server/scraping/extractor.py — specifically the text-matching logic in Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[send_message called] --> B[Navigate to profile page]
B --> C{profile_urn provided?}
C -- Yes --> D[Build compose URL directly]
C -- No --> E[_resolve_message_compose_href]
E --> F{Found anchor href?}
F -- Yes --> G[return href URL]
F -- No --> H{Found Message button?}
H -- No --> N[return None]
H -- Yes --> I{Extract URN from data attrs / scripts?}
I -- Yes --> J[Build compose URL from URN]
I -- No --> N
D --> K{compose_url set?}
G --> K
J --> K
K -- Yes --> L[Navigate to compose URL]
K -- No --> M[_click_profile_message_button]
M -- Not found --> O[return message_unavailable]
M -- Clicked --> P[wait_for_selector textbox timeout=8s]
P --> Q[_wait_for_message_surface]
L --> Q
Q --> R{surface type?}
R -- recipient_picker --> S[_select_message_recipient]
S -- No match --> U[return recipient_resolution_failed]
S -- Matched --> V[_resolve_message_compose_box]
R -- composer --> V
R -- None --> V
V -- None --> W[return composer_unavailable]
V -- found --> X{confirm_send?}
X -- No --> Y[return confirmation_required]
X -- Yes --> Z[Type and send message]
Prompt To Fix All With AIThis is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 1405-1406
Comment:
**`text === 'nachr'` will never match a German button**
The check uses strict equality against `'nachr'`, but the actual German button label is `"Nachricht"` (full word). This means German-locale LinkedIn UIs will fall through to the click-button path with no URN, and then `_click_profile_message_button` won't find the button either (it has no German localization at all). The intent appears to be a prefix or substring check.
```suggestion
label.includes('message') ||
text === 'message' ||
text === 'messaggio' ||
text.startsWith('nachr') ||
text === 'mensaje'
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 2466-2479
Comment:
**`_click_profile_message_button` misses German and Spanish locales**
`_resolve_message_compose_href` attempts to detect German (`nachr`) and Spanish (`mensaje`) buttons, but `_click_profile_message_button` — which is called precisely when the resolve step failed to yield a URL — only handles English and Italian. A German or Spanish user whose button has no extractable URN will land in this fallback, the button won't be matched, and the function returns `false`, causing an immediate `message_unavailable` error.
```suggestion
const btn = Array.from(document.querySelectorAll('a, button')).find(el => {
if (!isVisible(el)) return false;
const label = normalize(el.getAttribute('aria-label') || '');
const text = normalize(el.innerText || el.textContent || '');
return (
label.includes('message') ||
label.includes('messaggio') ||
label.includes('messaggia') ||
label.includes('nachr') ||
label.includes('mensaje') ||
text === 'message' ||
text === 'messaggio' ||
text === 'messaggia' ||
text.startsWith('messaggio') ||
text.startsWith('message') ||
text.startsWith('nachr') ||
text === 'mensaje'
);
});
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 97-105
Comment:
**Broad unscoped textbox selector may match non-compose elements**
The new `'div[role="textbox"][contenteditable="true"]'` selector (second in the tuple) has no aria-label or scope restriction. It will match any visible contenteditable div on the page — including comment boxes, search inputs, or other overlays — before the scoped `main div[...]` entry is tried. In practice this is low risk when the overlay is the only textbox visible, but reordering it after the scoped selectors would be safer.
```suggestion
_MESSAGING_COMPOSE_FALLBACK_SELECTORS = (
_MESSAGING_COMPOSE_SELECTOR,
'main div[role="textbox"][contenteditable="true"]',
'main [contenteditable="true"][aria-label*="message"]',
'[contenteditable="true"][aria-label*="message"]',
'[contenteditable="true"][aria-label*="messaggio"]',
'[contenteditable="true"][aria-label*="scrivi"]',
'div[role="textbox"][contenteditable="true"]', # overlay outside main, any language
)
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix: send_message fails with message_una..." | Re-trigger Greptile |
| text === 'nachr' || | ||
| text === 'mensaje' |
There was a problem hiding this comment.
text === 'nachr' will never match a German button
The check uses strict equality against 'nachr', but the actual German button label is "Nachricht" (full word). This means German-locale LinkedIn UIs will fall through to the click-button path with no URN, and then _click_profile_message_button won't find the button either (it has no German localization at all). The intent appears to be a prefix or substring check.
| text === 'nachr' || | |
| text === 'mensaje' | |
| label.includes('message') || | |
| text === 'message' || | |
| text === 'messaggio' || | |
| text.startsWith('nachr') || | |
| text === 'mensaje' |
Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 1405-1406
Comment:
**`text === 'nachr'` will never match a German button**
The check uses strict equality against `'nachr'`, but the actual German button label is `"Nachricht"` (full word). This means German-locale LinkedIn UIs will fall through to the click-button path with no URN, and then `_click_profile_message_button` won't find the button either (it has no German localization at all). The intent appears to be a prefix or substring check.
```suggestion
label.includes('message') ||
text === 'message' ||
text === 'messaggio' ||
text.startsWith('nachr') ||
text === 'mensaje'
```
How can I resolve this? If you propose a fix, please make it concise.| const btn = Array.from(document.querySelectorAll('a, button')).find(el => { | ||
| if (!isVisible(el)) return false; | ||
| const label = normalize(el.getAttribute('aria-label') || ''); | ||
| const text = normalize(el.innerText || el.textContent || ''); | ||
| return ( | ||
| label.includes('message') || | ||
| label.includes('messaggio') || | ||
| label.includes('messaggia') || | ||
| text === 'message' || | ||
| text === 'messaggio' || | ||
| text === 'messaggia' || | ||
| text.startsWith('messaggio') || | ||
| text.startsWith('message') | ||
| ); |
There was a problem hiding this comment.
_click_profile_message_button misses German and Spanish locales
_resolve_message_compose_href attempts to detect German (nachr) and Spanish (mensaje) buttons, but _click_profile_message_button — which is called precisely when the resolve step failed to yield a URL — only handles English and Italian. A German or Spanish user whose button has no extractable URN will land in this fallback, the button won't be matched, and the function returns false, causing an immediate message_unavailable error.
| const btn = Array.from(document.querySelectorAll('a, button')).find(el => { | |
| if (!isVisible(el)) return false; | |
| const label = normalize(el.getAttribute('aria-label') || ''); | |
| const text = normalize(el.innerText || el.textContent || ''); | |
| return ( | |
| label.includes('message') || | |
| label.includes('messaggio') || | |
| label.includes('messaggia') || | |
| text === 'message' || | |
| text === 'messaggio' || | |
| text === 'messaggia' || | |
| text.startsWith('messaggio') || | |
| text.startsWith('message') | |
| ); | |
| const btn = Array.from(document.querySelectorAll('a, button')).find(el => { | |
| if (!isVisible(el)) return false; | |
| const label = normalize(el.getAttribute('aria-label') || ''); | |
| const text = normalize(el.innerText || el.textContent || ''); | |
| return ( | |
| label.includes('message') || | |
| label.includes('messaggio') || | |
| label.includes('messaggia') || | |
| label.includes('nachr') || | |
| label.includes('mensaje') || | |
| text === 'message' || | |
| text === 'messaggio' || | |
| text === 'messaggia' || | |
| text.startsWith('messaggio') || | |
| text.startsWith('message') || | |
| text.startsWith('nachr') || | |
| text === 'mensaje' | |
| ); | |
| }); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 2466-2479
Comment:
**`_click_profile_message_button` misses German and Spanish locales**
`_resolve_message_compose_href` attempts to detect German (`nachr`) and Spanish (`mensaje`) buttons, but `_click_profile_message_button` — which is called precisely when the resolve step failed to yield a URL — only handles English and Italian. A German or Spanish user whose button has no extractable URN will land in this fallback, the button won't be matched, and the function returns `false`, causing an immediate `message_unavailable` error.
```suggestion
const btn = Array.from(document.querySelectorAll('a, button')).find(el => {
if (!isVisible(el)) return false;
const label = normalize(el.getAttribute('aria-label') || '');
const text = normalize(el.innerText || el.textContent || '');
return (
label.includes('message') ||
label.includes('messaggio') ||
label.includes('messaggia') ||
label.includes('nachr') ||
label.includes('mensaje') ||
text === 'message' ||
text === 'messaggio' ||
text === 'messaggia' ||
text.startsWith('messaggio') ||
text.startsWith('message') ||
text.startsWith('nachr') ||
text === 'mensaje'
);
});
```
How can I resolve this? If you propose a fix, please make it concise.| _MESSAGING_COMPOSE_FALLBACK_SELECTORS = ( | ||
| _MESSAGING_COMPOSE_SELECTOR, | ||
| 'div[role="textbox"][contenteditable="true"]', # overlay outside main, any language | ||
| 'main div[role="textbox"][contenteditable="true"]', | ||
| 'main [contenteditable="true"][aria-label*="message"]', | ||
| '[contenteditable="true"][aria-label*="message"]', | ||
| '[contenteditable="true"][aria-label*="messaggio"]', | ||
| '[contenteditable="true"][aria-label*="scrivi"]', | ||
| ) |
There was a problem hiding this comment.
Broad unscoped textbox selector may match non-compose elements
The new 'div[role="textbox"][contenteditable="true"]' selector (second in the tuple) has no aria-label or scope restriction. It will match any visible contenteditable div on the page — including comment boxes, search inputs, or other overlays — before the scoped main div[...] entry is tried. In practice this is low risk when the overlay is the only textbox visible, but reordering it after the scoped selectors would be safer.
| _MESSAGING_COMPOSE_FALLBACK_SELECTORS = ( | |
| _MESSAGING_COMPOSE_SELECTOR, | |
| 'div[role="textbox"][contenteditable="true"]', # overlay outside main, any language | |
| 'main div[role="textbox"][contenteditable="true"]', | |
| 'main [contenteditable="true"][aria-label*="message"]', | |
| '[contenteditable="true"][aria-label*="message"]', | |
| '[contenteditable="true"][aria-label*="messaggio"]', | |
| '[contenteditable="true"][aria-label*="scrivi"]', | |
| ) | |
| _MESSAGING_COMPOSE_FALLBACK_SELECTORS = ( | |
| _MESSAGING_COMPOSE_SELECTOR, | |
| 'main div[role="textbox"][contenteditable="true"]', | |
| 'main [contenteditable="true"][aria-label*="message"]', | |
| '[contenteditable="true"][aria-label*="message"]', | |
| '[contenteditable="true"][aria-label*="messaggio"]', | |
| '[contenteditable="true"][aria-label*="scrivi"]', | |
| 'div[role="textbox"][contenteditable="true"]', # overlay outside main, any language | |
| ) |
Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 97-105
Comment:
**Broad unscoped textbox selector may match non-compose elements**
The new `'div[role="textbox"][contenteditable="true"]'` selector (second in the tuple) has no aria-label or scope restriction. It will match any visible contenteditable div on the page — including comment boxes, search inputs, or other overlays — before the scoped `main div[...]` entry is tried. In practice this is low risk when the overlay is the only textbox visible, but reordering it after the scoped selectors would be safer.
```suggestion
_MESSAGING_COMPOSE_FALLBACK_SELECTORS = (
_MESSAGING_COMPOSE_SELECTOR,
'main div[role="textbox"][contenteditable="true"]',
'main [contenteditable="true"][aria-label*="message"]',
'[contenteditable="true"][aria-label*="message"]',
'[contenteditable="true"][aria-label*="messaggio"]',
'[contenteditable="true"][aria-label*="scrivi"]',
'div[role="textbox"][contenteditable="true"]', # overlay outside main, any language
)
```
How can I resolve this? If you propose a fix, please make it concise.
Problem
send_messagewas returningmessage_unavailablefor 1st-degree connections because the code assumed LinkedIn renders the Message button as an<a href="/messaging/compose/...">anchor tag. LinkedIn's current UI renders it as a<button>that opens a floating chat overlay via JavaScript — no href, no page navigation.This caused three cascading failures:
_resolve_message_compose_hreffound no anchor → returnedNone→ immediate bail-outmainand to the Englisharia-label="Write a message", but the floating overlay sits outsidemainand uses localised labels (e.g."Scrivi un messaggio"in Italian)Fix
_resolve_message_compose_href— extended with a fallback that searches for the Message button byaria-labelor visible text (multilingual), then tries to extract the profile URN from data attributes or inline scripts to construct the compose URL directly._click_profile_message_button(new method) — clicks the Message button when no compose URL can be derived, triggering LinkedIn's overlay natively.send_message— when_resolve_message_compose_hrefreturnsNone, instead of bailing out immediately, calls_click_profile_message_buttonand then waits up to 8 seconds for the compose textbox to appear viawait_for_selector._MESSAGING_COMPOSE_FALLBACK_SELECTORS— added selectors that work outsidemainand cover localisedaria-labelvalues (messaggio,scrivi).Test
Verified on a real 1st-degree connection profile with LinkedIn UI set to Italian.
send_messagenow returnsstatus: "sent".Note
This fix was implemented with Claude Code using Claude Sonnet 4.6 (
claude-sonnet-4-6) by Anthropic. The model analysed the server logs, debug screenshots, and source code to identify the root cause and iterate on the fix until the message was successfully delivered.🤖 Generated with Claude Code