Metadata-Version: 2.1
Name: odoo-addon-social_media_linkedin_sync
Version: 17.0.1.0.0.2
Requires-Python: >=3.10
Requires-Dist: odoo-addon-social_media_linkedin>=17.0dev,<17.1dev
Requires-Dist: odoo-addon-social_media_sync>=17.0dev,<17.1dev
Requires-Dist: odoo>=17.0a,<17.1dev
Summary: Import LinkedIn publications, their figures, comments and reactions
Home-page: https://github.com/OCA/social
License: AGPL-3
Author: Binhex, Odoo Community Association (OCA)
Author-email: support@odoo-community.org
Classifier: Programming Language :: Python
Classifier: Framework :: Odoo
Classifier: Framework :: Odoo :: 17.0
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
Classifier: Development Status :: 4 - Beta
Description-Content-Type: text/x-rst

.. image:: https://odoo-community.org/readme-banner-image
   :target: https://odoo-community.org/get-involved?utm_source=readme
   :alt: Odoo Community Association

==========================
Social Media LinkedIn Sync
==========================

.. 
   !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
   !! This file is generated by oca-gen-addon-readme !!
   !! changes will be overwritten.                   !!
   !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
   !! source digest: sha256:bb1327ef549ec3eed6333e373ef9ed6bf752672c91f366cdd83363e029a2e064
   !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
    :target: https://odoo-community.org/page/development-status
    :alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
    :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
    :alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github
    :target: https://github.com/OCA/social/tree/17.0/social_media_linkedin_sync
    :alt: OCA/social
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
    :target: https://translation.odoo-community.org/projects/social-17-0/social-17-0-social_media_linkedin_sync
    :alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
    :target: https://runboat.odoo-community.org/builds?repo=OCA/social&target_branch=17.0
    :alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module brings back into Odoo what a LinkedIn page already
published: the posts themselves, the figures each of them collected, and
their comments and reactions.

It is the LinkedIn half of *Social Media Sync*, split from *Social Media
Linkedin* along the same line: what a call costs. The connector asks
LinkedIn for a fixed number of things per account — publish, delete, the
daily figures of the whole page — and that number does not change
whether the page published once or ten thousand times. Everything whose
cost grows with the history of the page lives here: reading the feed one
page at a time, asking LinkedIn about the publications missing from it a
hundred URNs at a time, and one call per comment thread. The figures of
the publications are read by *Social Media Linkedin*, in as many calls
as the 4 KB limit of the query string needs, whoever hands it the
identifiers.

An Odoo that only publishes on LinkedIn installs *Social Media Linkedin*
alone and pays for none of it.

Main features:

- Import of the publications of the page and of the statistics each of
  them collected, on demand and through the scheduled actions of *Social
  Media Sync*.
- Full resynchronization of a page, the only pass that notices a
  publication deleted on LinkedIn. Missing from the feed never marks
  anything on its own: LinkedIn is asked about each suspect by its URN,
  one call per hundred of them, and only a ``404`` writes the deletion.
  The `Posts
  API <https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api>`__
  publishes asynchronously (``PUBLISH_REQUESTED``), answers the author
  finder in reader context, and states that a page returning fewer
  results than asked is not the end of the feed — so a publication that
  is alive can be absent from a listing read whole.
- Publications deleted on LinkedIn recognised while the dashboard is
  being used: a reaction or a comment answered with a ``404`` makes Odoo
  ask LinkedIn about the publication itself, and only its confirmation
  marks the line as deleted.
- Comment threads read from the dashboard, with their replies, and
  *Recommend* on a publication and on each of its comments, which also
  withdraws the reaction it made.
- Detection of pages that moved since the last import, which puts a
  notice on the dashboard inviting the user to press *Update*. It costs
  at most two calls per account, and reuses the daily figures the
  connector already read on the same pass instead of asking LinkedIn for
  the same days twice.

**Table of contents**

.. contents::
   :local:

Installation
============

This module is installed on its own as soon as *Social Media Sync* and
*Social Media Linkedin* are both present, which is what a bridge module
is for: the choice to synchronize was already made by whoever installed
*Social Media Sync*, and this only keeps a LinkedIn page from being left
without the half that belongs to it.

Installing *Social Media Linkedin* alone is a valid installation: the
account is linked, publishes, deletes and shows the daily figures of its
page.

The mark the update check compares against
------------------------------------------

``social.account.linkedin_statistics_checkpoint`` is stored by this
module and holds the daily figures LinkedIn reported for the whole page
as of the last import. An account that has no mark yet is given one on
the first pass of the bihourly check and announced as having nothing to
import, so installing this module on a database that already publishes
on LinkedIn does not put the notice up on every account at once.

Configuration
=============

Nothing has to be configured for this module to work: it uses the
LinkedIn accounts and credentials of *Social Media Linkedin*, and the
scheduled actions of *Social Media Sync*.

It asks LinkedIn for nothing the connector does not already ask for.
Reading the feed of a page, its comments and its reactions travels on
``r_organization_social``, which *Social Media Linkedin* requests on its
own, granted by the Community Management API product that connector
already needs.

What does not follow on its own is the accounts already associated. **An
access token keeps the scopes it was issued with**, so an account
authorized before that permission was requested does not hold it and its
history cannot be imported until it is authorized again. Refreshing the
token is not enough: open the account, press *Update account* with
**Update keys** ticked and authorize on LinkedIn again.

The accounts concerned say so themselves, so nothing has to be looked
up: a warning is drawn on the account form for as long as the permission
is missing, the responsible user is notified by the check that runs
every two hours, and the import refuses with the name of the missing
permission instead of the bare error LinkedIn answers.

System parameters
-----------------

One key is worth knowing about, and it is only written by hand in
*Settings* > *Technical* > *Parameters* > *System Parameters*: until
then the module uses the default the code carries. It is bounded when it
is read, so a value outside its range is brought back into it.

+------------------------------------------------+---------+------------------------+----------+
| Parameter                                      | Default | Unit                   | Bounds   |
+================================================+=========+========================+==========+
| ``social_media_linkedin_sync.posts_max_pages`` | 50      | pages of 100           | 1 to 500 |
|                                                |         | publications           |          |
+------------------------------------------------+---------+------------------------+----------+

It is how far into the feed of a page one pass reads. The default covers
five thousand publications, and it is not a limit of LinkedIn: it is
what keeps a feed that never ends from looping forever.

**A page with a longer history has to raise it.** A feed read short
comes back partial, and the weekly full resync — the only pass that
notices a publication deleted on LinkedIn — does not look for deletions
in a feed it could not read whole. Nothing breaks and nothing is said
beyond a warning in the log naming the number of pages, so the symptom
is publications that stay *posted* in Odoo after being deleted on
LinkedIn.

Raising it costs calls: each page is one call against the Posts API, so
500 pages are 500 calls per pass of every account, plus what reading
their figures adds. That is why the ceiling exists.

Usage
=====

Importing what a page already published.
----------------------------------------

- The *Update* button of the dashboard imports the publications of the
  pages. Without this module that button refreshes the daily series of
  the page and the figures of the publications of the last 30 days; with
  it, it also brings in the publications Odoo does not have yet and the
  figures of the ones older than that window.
- The import discovers the publications three ways: one publication by
  its reference, the whole feed page by page, or — the ordinary path —
  the first page of the feed sorted by last modification, which is what
  catches what changed since the previous pass.
- It also downloads the medias of the publications created outside of
  Odoo, so those images appear on the dashboard only after the import.
- The publication mirrors what is online: an image removed from the post
  on LinkedIn is dropped from the dashboard card on the next import.
  Only the medias downloaded from LinkedIn are managed this way, so a
  file attached by hand in Odoo is never removed.
- An import of the whole page leaves a mark with the figures of the last
  watched days. The bihourly check compares against that mark, which is
  why importing is what takes the notice down; refreshing a single
  publication takes it down too but leaves the mark alone, since one
  publication says nothing about the rest of the page.
- The first import asks for the time series again on an account whose
  association could not read it, which is the retry of a backfill that
  failed and not a repetition of it. How far back the series goes is the
  connector's business: *Social Media Linkedin* fills it as far back as
  LinkedIn answers by day the moment the account is linked, and rebuilds
  it from the *Rebuild statistics history* button of the account form.

Comments and reactions.
-----------------------

- The comment thread of a publication is read from the dashboard, and
  its replies with it: LinkedIn serves the replies of a comment apart,
  so they are asked for when the thread is opened.
- A comment and a reply are written under the organization, and the
  thread offers to delete a comment: LinkedIn is the one deciding
  whether the organization may, and its refusal is reported instead of
  removing it here.
- *Recommend* is offered on the publication and on each of its comments,
  sent under the organization. LinkedIn holds one reaction per account
  and only ``LIKE`` is sent, so the entry is a toggle: it shows a filled
  thumb on what the organization already recommended, and pressing it
  there withdraws the reaction. What LinkedIn holds is read back on
  every import, so a reaction made or withdrawn from LinkedIn itself
  reaches the dashboard on the next pass.
- LinkedIn does not accept an image in a comment, so the composer offers
  no upload on a LinkedIn thread.

Publications deleted on LinkedIn.
---------------------------------

- Posts deleted directly on LinkedIn are marked as *Deleted on LinkedIn*
  in Odoo, so the dashboard keeps their history. Only the *Full resync*
  button and the weekly scheduled action notice them, not the ordinary
  import, so a deletion can take up to a week to be reported.
- Opening a publication asks LinkedIn first, so one deleted there is
  reported and marked right away instead of waiting for the weekly pass.

Full resync of a page
---------------------

- Go to *Social Media* > Configuration > Accounts, select the account
  and click on the *Full resync* button of the header.
- The *Update* button of the dashboard reads one page of the feed, the
  one sorted by last modification, and refreshes the figures of every
  publication by its identifier. That is enough for everything except
  one thing: it cannot notice that a publication was **deleted** on
  LinkedIn, because a publication missing from the page it read is not
  necessarily gone.
- *Full resync* is what reads the whole feed and marks as *Deleted on
  LinkedIn* what is no longer there. It is also the expensive pass, one
  call per hundred publications against the daily quota, so it asks for
  confirmation.
- The scheduled action *Social: Full resync of the accounts* does the
  same weekly for every account, so pressing the button is only needed
  to see a deletion reported sooner.
- A publication edited on LinkedIn long enough ago to have fallen off
  that first page keeps the text Odoo already had until a full resync.
  Its figures are refreshed all the same.

How the synchronization spends the LinkedIn quota
-------------------------------------------------

The `throttle
limits <https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/rate-limits>`__
of LinkedIn are counted per day, per application and **per endpoint**,
so what matters is not the total number of calls but how they spread.

- The scheduled action checking for updates costs **two calls per
  account and run**, whatever the number of publications, one when it
  does find something, and only one for an account already announcing
  publications to import or for one whose credentials expired: the daily
  series of the page is written for them all the same, and what is
  skipped is the second call, the peek at the feed that could only
  confirm what the dashboard already says or fail on a token known to be
  dead. No call at all is spent on an account with no organization
  linked, since the finder is addressed by organization and there is
  nothing to ask. It reads the figures LinkedIn reports for the whole
  page day by day and compares them against the ones the last import
  left: no publication is read one by one to decide whether the
  dashboard should announce updates. Running every two hours, that is
  around 24 calls a day per account.

- The **Update** button does not walk the feed. The statistics are asked
  for by publication identifier, and those identifiers are already
  stored in Odoo, so one page of the feed is enough: the one sorted by
  last modification, which is what brings the publications created or
  edited on LinkedIn. That is one call instead of one per hundred
  publications.

- The figures of the publications are not read here: the import hands
  the identifiers to *Social Media Linkedin*, which is where that
  reading lives, so the same three calls answer the page whether they
  are asked for by this import or by the daily refresh of the connector.

- Those figures are asked in as many calls as the 4 KB limit of the
  query string needs, since those endpoints take every identifier in the
  URL and none of them paginates. Around a hundred publications fit in
  one call, so a full feed takes several.

  https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/share-statistics

- *Full resync* is the expensive pass, one call per hundred
  publications. The button does it for the account it is pressed on; the
  weekly scheduled action does it for every account of the database. It
  walks the feed page by page (``count=100``) up to as many pages as
  ``social_media_linkedin_sync.posts_max_pages`` allows, **fifty by
  default**, that is 5000 publications, and a page is only taken as the
  last one when it comes back empty, because LinkedIn documents that a
  page may carry fewer publications than asked while more are left. On
  an account whose feed is longer than that, the answer is incomplete
  and the sweep that marks as *Deleted* what is no longer on LinkedIn is
  skipped for that run: reporting nothing is preferable to marking a
  publication that is still online.

What the check for updates does and does not notice
---------------------------------------------------

- It only tells that *something* on the page moved, not which
  publication moved. It does not need to: the only thing it decides is
  whether the dashboard shows the notice inviting to synchronize. A
  change that cancels itself out between two runs, a reaction removed
  and another one added, leaves the figures of the page where they were
  and goes unnoticed until the next synchronization.
- It watches the **daily** figures of the page, over a window of the
  last seven days, and not the lifetime totals the same endpoint answers
  when no time interval is given. The two are two separate reads of
  LinkedIn's own data, so they are not guaranteed to agree at every
  instant; this module only relies on the daily buckets. One consequence
  worth knowing: activity older than the seven-day window is not
  announced. It is imported all the same when the user synchronizes,
  because the import reads the publications themselves and not this
  window.
- The engagement is not compared. It is a ratio of the clicks,
  reactions, comments and shares over the impressions, so it cannot move
  without one of those moving, and it is the only non-integer figure of
  the set.
- The statistics endpoint only answers activity of the **last 12
  months**, on a rolling window. A publication older than that stops
  being counted, which can move the figures of the page on its own, with
  nobody having interacted with anything. The check announces updates
  once when that happens and the next import reconciles it.

Known issues / Roadmap
======================

One definition per symbol
-------------------------

The calls of this module cross towards *Social Media Linkedin*, never
the other way around: ``_request_linkedin``, ``_get_posts``,
``_linkedin_statistics_values``, ``_get_linkedin_daily_statistics``,
``_refresh_post_statistics`` with the reading by URN behind it —
``_get_entity_statistics``, ``_get_entity_share_statistics``,
``_get_ugc_posts_statistics``, ``_parse_share_statistics``,
``_filter_urns`` — and the constants of ``social_linkedin_utils.py`` are
asked for where the connector defines them. The import does not read the
figures of a publication either: it hands the page it discovered, plus
the publications the page did not bring, to ``_refresh_post_statistics``
and lets the connector spend the calls. Copying one of them here would
be the failure that shows in nothing: the two copies drift apart with
the first change, and the daily series and the import mark stop speaking
the same language.

Where the connector needs something only this module knows how to do, it
declares an empty hook — ``_linkedin_check_updates`` — and this module
overrides it.

Where the daily series is filled
--------------------------------

``_backfill_statistics`` is the empty hook of *Social Media Base* and
*Social Media Linkedin* implements it, so neither the method nor the
calls it spends belong to this module. Base asks for it the moment an
account is associated, the *Rebuild statistics history* button of the
account form asks for it again with ``force=True``, and the initial
synchronization of *Social Media Sync* asks for it once more, which on
an account that already holds its series is the retry of an association
whose history could not be read rather than a repetition of it.

They are the figures of the whole page --what the finder answers without
a list of URNs counts what was published before Odoo and outside of it--
so what this module changes is when the series is asked for again, not
what is being counted.

``_snapshot_statistics``, ``_linkedin_backfill_window`` and
``_STATISTICS_HISTORY_MONTHS_LINKEDIN`` stay in the connector, and so do
the calls that use them: ``_backfill_statistics`` is the only caller of
the three. This module asks for none of them.

Who wrote a comment
-------------------

- **A person cannot be named.** ``GET /v2/socialActions/{urn}/comments``
  identifies the author of a comment by a URN and nothing else, and no
  public endpoint turns a ``urn:li:person:`` into a name: the `Profile
  API <https://learn.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api>`__
  answers for the authenticated member alone,
  ``r_1st_connections_profile`` was retired, and the token the connector
  holds belongs to the page and not to a person. The website shows the
  name because it reads endpoints of its own that are not exposed.
  Reading it needs the Partner Program, an access level of the
  application.
- What the module does with that limit is resolve what can be resolved
  and say nothing where it cannot. The account itself is named from
  Odoo, an organization is read with ``GET /v2/organizations/{id}``, and
  everything else is drawn as *LinkedIn member* or *LinkedIn page*. A
  comment is never signed with the name of the page that did not write
  it.
- An organization the account does not administer may answer ``403``.
  The thread keeps loading, that comment falls back to the neutral label
  and the refusal is only logged: a name is not worth a thread.
- The names are resolved once per thread and not kept. A cache of actors
  would save the calls a reopened dialog repeats, and it would need its
  own expiry and its own security; it is not implemented.

Media in the comments
---------------------

- A comment is published with its text alone: the connector sends
  ``actor``, ``message`` and ``object``, never ``content``, and the
  comment composer offers no attachment for a LinkedIn publication.
- The `Comments
  API <https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/comments-api>`__
  documents an example carrying media, and it is not what an ordinary
  application gets. Measured against a real page on 2026-08-24, with an
  image already uploaded through the Images API, the two ways of sending
  it answer:

  - ``POST /rest/socialActions/{urn}/comments`` — ``403 ACCESS_DENIED``,
    *"Not enough permissions to access:
    partnerApiSocialActions.CREATE"*. The versioned resource belongs to
    the Partner Program, which is an access level of the application and
    not something a paid plan buys.
  - ``POST /v2/socialActions/{urn}/comments``, the endpoint the
    connector talks to, with the same payload plus ``content`` —
    ``500 Internal Server Error``, while the very same call without
    ``content`` publishes the comment.

- So the media stays out until LinkedIn grants that access to the
  application: the endpoint that would take it is refused, and the one
  that answers does not accept the field.

The weekly full resync is not spread over the days
--------------------------------------------------

- The *Full resync* button reconciles **one account**, the one it is
  pressed on. The scheduled action does not: it walks every account of
  the database in the same weekly run, each in its own savepoint, and
  that is the expensive pass, one call per hundred publications of each
  account.
- On a database with several large pages that single run could exhaust
  the daily quota and make the manual refresh fail with it. Spreading
  the accounts over the days of the week is the way out and is not
  implemented.

Asking LinkedIn only for what changed
-------------------------------------

- **It is not possible with these endpoints**, so the statistics cost
  one call per batch of identifiers whatever moved:

  - The ``/posts`` finder takes ``author``, ``start``, ``count``,
    ``sortBy`` and ``viewContext``. There is no ``since`` of any kind,
    so there is no equivalent to what the X connector does with
    ``since_id``.
  - The statistics of specific publications cannot be restricted to a
    period: *"Time-bound statistics is not supported for specific share
    queries"*.
  - The daily figures of the page say that something moved, not which
    publication moved.

- There **is** a stream of engagement events, and it is not used. It
  would tell which publication changed, and with it the refresh could
  ask about that one alone. It is not implemented because of three
  limits worth weighing first: it only reports reactions, comments and
  reshares, never impressions or clicks, which would still need the
  ordinary refresh; the publication it points at comes as an
  ``urn:li:activity:`` and only the webhook payload carries the matching
  ``urn:li:share:``, the pull finder does not, so pulling alone cannot
  attribute an event to a publication; and the webhook needs a publicly
  reachable HTTPS URL validated by LinkedIn plus a subscription per
  member and organization. The permission it needs,
  ``rw_organization_admin``, is already requested by *Social Media
  Linkedin*.

  https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-social-action-notifications

Reactions other than Like
-------------------------

- Only ``LIKE`` is sent, both on a publication and on a comment. The
  `Reactions
  API <https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/reactions-api>`__
  also takes ``PRAISE``, ``EMPATHY``, ``INTEREST``, ``APPRECIATION`` and
  ``ENTERTAINMENT``. Nothing on the LinkedIn side holds them back — the
  same endpoint and the same permissions serve them — so offering them
  is a matter of choosing the reaction on the dashboard, and of drawing
  an entry that is no longer one thumb with two states.

Video of a publication
----------------------

- The video of a published post is not attached to the publication
  imported from LinkedIn: only the *has video* flag is kept. Bringing
  the file over means downloading it from LinkedIn on every
  synchronization, which is not implemented.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/social/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/social/issues/new?body=module:%20social_media_linkedin_sync%0Aversion:%2017.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Binhex

Contributors
------------

- `Binhex <https://www.binhex.cloud>`__:

  - Edilio Escalona Almira e.escalona@binhex.cloud
  - Jorge Elena Poblet j.elena@binhex.cloud

Other credits
-------------

This work has been funded by AEOdoo (Asociación Española de Odoo -
https://www.aeodoo.org)

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
   :alt: Odoo Community Association
   :target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

.. |maintainer-edescalona| image:: https://github.com/edescalona.png?size=40px
    :target: https://github.com/edescalona
    :alt: edescalona

Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-edescalona| 

This module is part of the `OCA/social <https://github.com/OCA/social/tree/17.0/social_media_linkedin_sync>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
