Skip to content

fix: send_message fails with message_unavailable on modern LinkedIn UI#383

Open
IT-AlIan wants to merge 1 commit intostickerdaniel:mainfrom
IT-AlIan:fix/send-message-overlay-ui
Open

fix: send_message fails with message_unavailable on modern LinkedIn UI#383
IT-AlIan wants to merge 1 commit intostickerdaniel:mainfrom
IT-AlIan:fix/send-message-overlay-ui

Conversation

@IT-AlIan
Copy link
Copy Markdown

Problem

send_message was returning message_unavailable for 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:

  1. _resolve_message_compose_href found no anchor → returned None → immediate bail-out
  2. Even if the button had been clicked, the code checked for the compose textbox immediately (no wait), so it would miss the asynchronously rendered overlay
  3. The compose box selectors were restricted to main and to the English aria-label="Write a message", but the floating overlay sits outside main and 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 by aria-label or 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_href returns None, instead of bailing out immediately, calls _click_profile_message_button and then waits up to 8 seconds for the compose textbox to appear via wait_for_selector.

_MESSAGING_COMPOSE_FALLBACK_SELECTORS — added selectors that work outside main and cover localised aria-label values (messaggio, scrivi).

Test

Verified on a real 1st-degree connection profile with LinkedIn UI set to Italian. send_message now returns status: "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

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 19, 2026

Greptile Summary

This PR fixes send_message failing with message_unavailable on modern LinkedIn profiles where the Message button is a <button> (no href) that opens a floating overlay. It adds a multi-stage fallback in _resolve_message_compose_href (URN extraction from DOM attributes and inline scripts), a new _click_profile_message_button helper, and broader compose-box selectors covering localised aria-labels outside main.

  • P1: text === 'nachr' in _resolve_message_compose_href uses strict equality — it will never match \"Nachricht\" (German); needs text.startsWith('nachr').
  • P1: _click_profile_message_button handles English and Italian but omits German (nachr) and Spanish (mensaje), which are handled in the sibling resolve step — a German/Spanish user whose button yields no URN will get message_unavailable again.

Confidence Score: 4/5

Core 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 text === 'nachr' strict equality is a clear logic bug that silently skips German-locale buttons, and _click_profile_message_button lacks the German/Spanish terms needed for its role as the last-resort fallback. These are present defects on specific locale paths.

linkedin_mcp_server/scraping/extractor.py — specifically the text-matching logic in _resolve_message_compose_href (line 1405) and _click_profile_message_button (lines 2466-2479).

Important Files Changed

Filename Overview
linkedin_mcp_server/scraping/extractor.py Adds overlay-aware message sending via button click fallback; two P1 issues: German locale never matches due to text === 'nachr' strict equality (should be startsWith), and _click_profile_message_button lacks German/Spanish localization present in the resolve step.

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]
Loading
Prompt To Fix All 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.

---

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

Comment on lines +1405 to +1406
text === 'nachr' ||
text === 'mensaje'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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.

Comment on lines +2466 to +2479
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')
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 _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.

Suggested change
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.

Comment on lines 97 to 105
_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"]',
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant