diff --git a/requirements.txt b/requirements.txt index c84237f9f9..5866bed8b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ # generated from manifests external_dependencies odoo_test_helper +requests_oauthlib requests_toolbelt +tweepy diff --git a/social_media_x/README.rst b/social_media_x/README.rst new file mode 100644 index 0000000000..04c7edf1d7 --- /dev/null +++ b/social_media_x/README.rst @@ -0,0 +1,546 @@ +============== +Social Media X +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:dce4cef08fdfe08eb3cbdc63e90942bd6f44b8b7090045d2fef34b660c0b71d5 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-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_x + :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_x + :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 provides the necessary functionality for basic interaction +with the X social media. + +Main features: + +- User account integration. +- Post creation. +- Comments on the publications (read and reply). +- Reports and graphs with agnostic metrics. + +Liking a publication or a comment is not available on X: the *like* and +*follow* endpoints were `removed by +X `__, +so the button is not shown for this media on any plan. + +Statistics account +------------------ + +In the case of X statistics, only current posts are taken into account; +if some are deleted, the metrics also decrease, that is, it is not a +history of the account's posts. + +The metrics are computed on the **100 most recent publications** X +answers in a single request, and only on original publications: +retweets, replies and quotes are discarded both when importing and when +computing. The publications older than those 100 are not counted, +because the `timeline `__ +is not paginated, in order to spare the requests of the plan. + +1. The eye icon: Total number of views, which may include multiple views + by the same user. + +2. The hand icon: means the interactions (likes, comments, retweets and + quotes) of current posts. They are the + `metrics `__ X returns + for a post. + +3. The star icon: means the value of the engagement of the publications, + it is a calculation similar to (interactions / impressions) \* 100. + + |STATISTICS_ACCOUNT| + +If the X API `rate +limit `__ is reached +while refreshing the statistics, the charts reuse the last metrics +stored on the account instead of failing. + +The charts of an X account always show the totals of its current +publications: the date range and the granularity do not filter the data, +because the `metrics `__ +X returns are the current ones of each post and not a history per day. +They only change the way the data is drawn. + +.. |STATISTICS_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/STATISTICS_ACCOUNT.png + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +This module requires the following Python libraries: + +- ``tweepy`` 4.0 or above, because the module uses the client of the `X + API v2 `__ + (``tweepy.Client``) besides the v1.1 client for the media upload. +- ``requests_oauthlib`` + +You can install them with: + +:: + + pip install tweepy requests_oauthlib + +Configuration +============= + +To configure this module, you need to: +-------------------------------------- + +Please note that you must have a developer account. + +**Important:** the X API v2 endpoints used by this module require a +developer App **attached to a Project** and a **paid plan** (Pay Per +Use, or a legacy Basic/Pro subscription), see `about the X +API `__. Since +February 2026 the Free access tier no longer grants general access to +the API, so the account cannot be associated with a Free-tier App. The +association wizard shows this warning, and when X rejects the request +for this reason the module replaces the raw error with a message +pointing to the pricing page. Note that the authorization screen of X is +still shown with a Free-tier App: the rejection only happens afterwards, +when the module reads the authorized user, and the message is then +displayed on the Dashboard. See the ROADMAP section for details. + +The steps required for using it are defined below: + +- Go to https://developer.twitter.com/en/portal/dashboard + +- Create a developer account. + +- Once the account is created, go to Projects and APPS -> Default + Project and select it. + + |PROJECT_DEFAULT| + +- Then scroll to the bottom of the page and press the Edit button. + + |CONFIGURATION_ACCOUNT| + +- Once on the page, in the App Permissions section, select the Read and + Write and Direct Messages. + + |APP_PERMISIONS| + +- Go to the App Type section and select Web App, Automated App or Bot. + + |TYPE_APP| + +- Then, in the Callback URI / Redirect URL section, add a new address. + Here are the steps to get that URL in Odoo: + + - Go to *Configuration* > *Technical* > System Parameters. + - Search for web.base.url + - Copy the base URL and concatenate it with the endpoint. Example: + web.base.url: http://192.168.1.7:8017 endpoint: /social_x/callback + (this value is fixed) callback_url: + http://192.168.1.7:8017/social_x/callback + +- Then go to the Website URL section and add your X profile address. + + Example: https://x.com/AccountTest + + |CALLBACK_URI_WEBSITE_URL| + +- Finally, go to Projects and APPS -> Default Project -> Keys and + Tokens, press the Regenerate button, and then in the window that + appears, confirm the generation of the API Key and API Key Secret + values. + + |KEYS_AND_TOKENS| + + |GENERATE_NEW_KEY_SECRET| + +Learn more at `X Developer Portal `__ + +Registering the API Key and API Key Secret. Integration of a user account. +-------------------------------------------------------------------------- + +- Go to *Social Media* > Configuration > Social medias + +- Click on the *Associate Account* button for the desired social media. + + |ASSOCIATE_ACCOUNT| + +- A wizard will open for you to add the API Key and API Key Secret + obtained from your developer account. + + |WIZARD_ASSOCIATE_ACCOUNT| + +- By clicking the *Associate* button here, you'll be taken to a X + authentication page. Once you validate your information, you'll be + taken to the system and the Dashboard view, where you'll see your + posts. + + |AUTHORIZE_ACCOUNT| + +- Once you have completed these steps and everything is working + correctly, you can see your account in *Social Media* > Configuration + > Accounts + +- After the account is associated, an initial synchronization of its + posts and statistics is triggered automatically. + +- Note that the account credentials (API Key, API Secret and tokens) are + only visible to users with administration rights (*Settings*). The + account form shows the API Key in read-only mode and the API Secret + masked; the tokens are never displayed. + +- The authorization flow is bound to the session that started it: the + request token returned by X only works for the user who opened the + wizard. A callback without a request token, or with the request token + of another user, is refused without creating or modifying anything; + the user gets the generic notice *The account could not be associated. + Check the server log for details.* and the exact reason is kept in the + server log, so the raw answer of the provider is never exposed. + +- Each X account has to be associated with its own pair of credentials: + if an account already exists (even archived) with the same *API Key* + and *API Secret*, the association is refused with *An account with + that information already exists.* Create a different developer App for + that account. + +- Besides the OAuth 1.0a authorization of the user, the module obtains + an application-only *bearer token* (OAuth 2.0 *client credentials*) + from the same API Key and API Secret; that is the one used to read + publications, comments and statistics, see `about the X + API `__. If X + does not deliver it, the account is not created and the notice *The + account was not created: the OAuth2 access token could not be + obtained.* is shown. In the same way, if X does not answer the access + token correctly when closing the authorization, the account is not + created and the error returned by X is shown. + +- Publishing with images or video uses the `media + upload `__ endpoint of + the X API v1.1 besides the v2 endpoints. The App has to have access to + both: on a plan without that access, the publications with attachments + fail even though the text-only ones work. + +- The X account being authorized has to be active and not protected, and + the App needs read and write permission. Odoo does not check it + beforehand: if X refuses, the error returned by X is displayed on the + Dashboard and the account is not associated. + +- If the X account is already linked to an account of another Odoo user, + the association is refused and nothing is overwritten: only the + responsible user of the account and the *Social Media / Administrator* + group can relink it. + +.. |PROJECT_DEFAULT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/PROJECT_DEFAULT.png +.. |CONFIGURATION_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/CONFIGURATION_ACCOUNT.png +.. |APP_PERMISIONS| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/APP_PERMISIONS.png +.. |TYPE_APP| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/TYPE_APP.png +.. |CALLBACK_URI_WEBSITE_URL| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/CALLBACK_URI_WEBSITE_URL.png +.. |KEYS_AND_TOKENS| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/KEYS_AND_TOKENS.png +.. |GENERATE_NEW_KEY_SECRET| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/GENERATE_NEW_KEY_SECRET.png +.. |ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/ASSOCIATE_ACCOUNT.png +.. |WIZARD_ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png +.. |AUTHORIZE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/AUTHORIZE_ACCOUNT.png + +Usage +===== + +List of posts generated from Odoo. +---------------------------------- + +Only posts generated using Odoo are displayed. + +- Go to *Social Media* > Post + +Generate a post. +---------------- + +This feature acts as a template for generating multiple posts from a +single view, depending on the selected accounts. + +- Go to *Social Media* > Post > New or Go to *Social Media* > Dashboard + > Add Post +- Fill in the required fields +- When the post is created, every X account of the active company is + selected by default in *Accounts*; review the list and remove the ones + you do not want to use before saving. |CREATE_POST| +- Save +- Click on the *Post* button + +Update token, API Key, API Secret and account data +-------------------------------------------------- + +- Go to *Social Media* > Configuration > Accounts + +- Select the account + +- Click on the *Update account* button + + |BUTTON_UPDATE_ACCOUNT| + +- In the wizard that appears, if none of the checkboxes are selected and + the *Update* button is pressed, the system will update only the + account's data. + +- If the *Update keys* checkbox is selected, the current API Key and API + Secret values will be displayed by default. Modify any of these values + and authentication will be performed again through X to update these + values and the token. + + |UPDATE_KEYS| + +- Selecting the *Update token* checkbox will update the current token. + + |UPDATE_TOKEN| + +Archive Account X +----------------- + +- Go to *Social Media* > Configuration > Accounts + +- Select the account + +- Click on the *Archive account* button + + |ARCHIVE_ACCOUNT| + +- Please note that all data associated with this account will be + archived. + +- To use an archived account again, open it in *Social Media* > + Configuration > Accounts (*Archived* filter) and press *Update + account*: the account and its data are reactivated instead of creating + a duplicate. The *Associate Account* wizard is not the way to do it + when the same API Key and API Secret are reused, because it refuses + the keys already registered on another account, archived ones + included. + +- An archived account can be deleted permanently with the *Delete + permanently* button, only available to a social media administrator. + The X publications stay online, only the Odoo history is removed. + +Enable since +------------ + +- Go to *Social Media* > Configuration > Accounts + +- Select the account + +- Select *Enable since* + +- The *Post since* field is then enabled, allowing you to select the + post to start the search for in the next post retrieval. Note that + metrics for older posts will not be updated if this option is + selected. + + |ENABLE_SINCE| + +X limits and validations +------------------------ + +The module does not enforce the content limits of X: they are applied by +X when the publication is sent, so the failure is only seen at that +moment. + +- Odoo does not check the length of the message: X limits a publication + to 280 characters (more if the account has X Premium), see the + `creation of a + post `__. If it is + exceeded, the line is left as *Failed* with the error returned by X. +- X accepts at most **4 images or 1 video** per publication and does not + allow mixing images and video in the same message, see the `media + upload `__ documentation. + Odoo does not validate it: if the post does not respect it, X refuses + the publication and the reason is kept on the failed line. +- The size of the files is not checked either: the limits of X apply + (about 5 MB per image, and 512 MB / 140 seconds per video). A bigger + file makes the upload fail and the publication is left as *Failed*. +- The images of the post are limited to JPG/JPEG and PNG, and the videos + to MP4. That is the filter of the file picker, not a server + validation: a file attached another way is refused by X instead. + +Rate limits +----------- + +- The `rate limit `__ + is tracked per endpoint (reading publications, publishing, comments, + deletion and charts). When X answers that it is exhausted, Odoo stores + the window it returns and does not call that endpoint again until it + expires: a notice is shown with the limit of the plan, the remaining + requests and the time of the next attempt. If X does not say when the + window resets, 60 seconds are assumed. +- If X refuses a publication because the requests of the plan are + exhausted, the line is left as *Failed* with the message *X did not + accept the post. The account may have reached the limit of requests of + its plan: check the account and try again later.* It is not a content + error: wait for the window to end and press *Post* again, which only + sends the failed accounts. + +Comments and dashboard actions +------------------------------ + +- The comments of a publication are read with the `recent + search `__ endpoint of + X, which only covers the **last 7 days**: the replies to older + publications are not shown on the dashboard even though the post has + them. Retweets and quotes are excluded as well, only the replies are + listed. +- Before acting on a publication from the dashboard, Odoo checks on X + that the post still exists. If it was deleted directly on X, the + action stops showing the error returned by X. +- Deleting a publication from the dashboard deletes the post on X first. + If X refuses the deletion, the operation is cancelled with the error + returned by X and the publication keeps existing both on X and in + Odoo. +- The direct calls to X made while linking the account (token requests + and download of the profile image) have a timeout of 10 seconds. If + the network or X take longer, the association fails and has to be + repeated. + +X credentials +------------- + +The `OAuth +1.0a `__ +tokens of X do not expire, so there is nothing to renew: they only stop +working when the access is revoked from the X application or the keys +are changed. When that happens X refuses the publication, the reason is +kept on the failed publication and the account is marked as needing an +update. Odoo cannot renew it by itself: associate the account again from +*Update account*. + +Uninstalling the module +----------------------- + +Uninstalling *Social Media X* does not delete the accounts nor their +publication history: + +- The access tokens are cleared, so no credential outlives the module. +- The X accounts are archived, together with their posts. +- The X specific data is lost, because Odoo drops the columns of an + uninstalled module: the API Key, the API Secret and the OAuth 1 + tokens. +- The identifier of each account and publication on X is kept, so + installing the module back and associating the account again + reactivates the archived history and updates it, instead of importing + everything as duplicated records. + +.. |CREATE_POST| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/CREATE_POST.png +.. |BUTTON_UPDATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/BUTTON_UPDATE_ACCOUNT.png +.. |UPDATE_KEYS| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/UPDATE_KEYS.png +.. |UPDATE_TOKEN| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/UPDATE_TOKEN.png +.. |ARCHIVE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/ARCHIVE_ACCOUNT.png +.. |ENABLE_SINCE| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/ENABLE_SINCE.png + +Known issues / Roadmap +====================== + +- Since February 2026, X deprecated the Free access tier: it no longer + grants general access to the X API v2 endpoints used by this module + (reading posts, publishing, comments, statistics). API calls made with + a Free-tier account return ``403 Forbidden``, either with + ``"reason": "client-not-enrolled"`` or asking for an App attached to a + Project, since only a paid App can belong to one. A paid plan is + therefore required to use this module: the default **Pay Per Use** + plan, or the legacy Basic/Pro plans for existing subscribers. + + |FREE_PLAN_DEPRECATED| + + - X API pricing and plans: + https://docs.x.com/x-api/getting-started/pricing + - Removal of the like and follow endpoints: + https://devcommunity.x.com/t/update-to-x-api-free-tier-removal-of-like-and-follow-endpoints/247646 + - Rate limits according to plan: + https://docs.x.com/x-api/fundamentals/rate-limits + - How to get access to the API: + https://docs.x.com/x-api/getting-started/getting-access + +- Liking a publication or a comment is not supported: X removed the + *like* and *follow* endpoints, so the button is hidden for the X + publications whatever the plan is, not only on the free tier. + +- Also keep in mind that if you want to post the same content on the + same accounts (username X), the API will not allow it for spam + reasons: see the `creation of a + post `__. To avoid + that rejection, Odoo does not let a post that selects two X accounts + with the same *username* be saved: the error *There are X accounts + with the same username (...), please check to avoid spam errors.* is + shown. + +- The X accounts do not take part in the automatic check for updates + that runs every 30 minutes: their statistics are refreshed when the + account is linked, with the *Update* button of the dashboard and with + the monthly synchronization. This is on purpose, to spare the requests + of the API plan. + +.. |FREE_PLAN_DEPRECATED| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_x/static/img/readme/FREE_PLAN_DEPRECATED.png + +Bug Tracker +=========== + +Bugs are tracked on `GitHub 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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* BinhexTeam + +Contributors +------------ + +- `Binhex `__: + + - Edilio Escalona Almira e.escalona@binhex.cloud + +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 `__: + +|maintainer-edescalona| + +This module is part of the `OCA/social `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/social_media_x/__init__.py b/social_media_x/__init__.py new file mode 100644 index 0000000000..dc97a09a9f --- /dev/null +++ b/social_media_x/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from . import controllers +from . import models +from . import wizards + +from odoo.addons.social_media_base import remove_social_media + + +def uninstall_hook(env): + remove_social_media(env, "x") diff --git a/social_media_x/__manifest__.py b/social_media_x/__manifest__.py new file mode 100644 index 0000000000..82913c8043 --- /dev/null +++ b/social_media_x/__manifest__.py @@ -0,0 +1,35 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Social Media X", + "summary": "Integration of the X social media.", + "version": "17.0.1.0.0", + "category": "Social Network", + "development_status": "Beta", + "license": "AGPL-3", + "uninstall_hook": "uninstall_hook", + "author": "BinhexTeam,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/social", + "maintainers": ["edescalona"], + "depends": [ + "social_media_base", + ], + "data": [ + "data/social_media_data.xml", + "views/social_account_views.xml", + "wizards/wizard_social_account.xml", + ], + "assets": { + "web.assets_backend": [ + "social_media_x/static/src/js/views/**/*.js", + ], + }, + "external_dependencies": { + "python": [ + "tweepy", + "requests_oauthlib", + ], + }, + "installable": True, +} diff --git a/social_media_x/controllers/__init__.py b/social_media_x/controllers/__init__.py new file mode 100644 index 0000000000..cc4badeca5 --- /dev/null +++ b/social_media_x/controllers/__init__.py @@ -0,0 +1 @@ +from . import social_media_x diff --git a/social_media_x/controllers/social_media_x.py b/social_media_x/controllers/social_media_x.py new file mode 100644 index 0000000000..1f226071fa --- /dev/null +++ b/social_media_x/controllers/social_media_x.py @@ -0,0 +1,46 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, http +from odoo.http import request, route + +_logger = logging.getLogger(__name__) + + +class SocialMediaX(http.Controller): + """Endpoint called by X to close the OAuth 1.0a flow.""" + + @route( + ["/social_x/callback"], + type="http", + auth="user", + ) + def social_x(self, **kwargs): + SocialAccount = request.env["social.account"] + try: + # The request token lives on the wizard, whose credentials are + # restricted to the system group; the wizard is looked up by + # ``oauth_token`` and ``create_uid`` inside ``_get_access_token``. + access_token, access_token_secret = SocialAccount.sudo()._get_access_token( + kwargs + ) + if access_token and access_token_secret: + SocialAccount.create_account_x( + access_token, access_token_secret, kwargs + ) + except Exception: # noqa: BLE001 - the provider may fail in any way + # The exception may carry the raw provider response, so the user + # only gets a generic message and the detail stays in the log. + SocialAccount._notify_user_session( + SocialAccount._format_user_notification( + _( + "The account could not be associated. " + "Check the server log for details." + ), + media="X", + ) + ) + _logger.exception("Error creating the X account") + return request.redirect(SocialAccount._get_social_dashboard_url()) diff --git a/social_media_x/data/social_media_data.xml b/social_media_x/data/social_media_data.xml new file mode 100644 index 0000000000..7ea86eef37 --- /dev/null +++ b/social_media_x/data/social_media_data.xml @@ -0,0 +1,15 @@ + + + + + X + x + Manage your X account and schedule posts + + + diff --git a/social_media_x/i18n/es.po b/social_media_x/i18n/es.po new file mode 100644 index 0000000000..c48d75aa61 --- /dev/null +++ b/social_media_x/i18n/es.po @@ -0,0 +1,345 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * social_media_x +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-03 23:00+0000\n" +"PO-Revision-Date: 2026-08-03 02:57+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_api_key +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__x_api_key +msgid "API Key" +msgstr "Clave de API" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_api_secret +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__x_api_secret +msgid "API Secret" +msgstr "Llave secreta API" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/wizards/wizard_social_account.py:0 +#, python-format +msgid "" +"Account access could not be authorized. Please check your settings or try " +"again later." +msgstr "" +"No se pudo autorizar el acceso a la cuenta. Revisa tu configuración o " +"inténtalo de nuevo más tarde." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/wizards/wizard_social_account.py:0 +#, python-format +msgid "An account with that information already exists." +msgstr "Ya existe una cuenta con esa información." + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_wizard_social_account +msgid "Associate Social Media Account" +msgstr "Asociar Cuenta de Red Social" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "ERROR DELETE POST X: %(error)s" +msgstr "ERROR AL ELIMINAR EL POST DE X: %(error)s" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__enable_since +msgid "Enable Since" +msgstr "Habilitar desde" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__engagement +msgid "Engagement" +msgstr "Engagement" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "Error Comment Tweet: %(error)s" +msgstr "Error al comentar el tweet: %(error)s" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "Error Get Comment Post: %(error)s" +msgstr "Error al obtener el comentario del post: %(error)s" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "Error Get Comments for Tweet: %(error)s" +msgstr "Error al obtener los comentarios del tweet: %(error)s" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "Error getting X access token: %(error)s" +msgstr "Error al obtener el token de acceso de X: %(error)s" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"Invalid X callback: the request token is missing. Please restart the account " +"association process." +msgstr "" +"Retorno de llamada de X no válido: falta el token de solicitud. Reinicie el " +"proceso de asociación de la cuenta." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"Invalid X request token. Please restart the account association process." +msgstr "" +"Token de solicitud de X no válido. Reinicie el proceso de asociación de la " +"cuenta." + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__last_post_ref +msgid "Last Post" +msgstr "Último post" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_media__media_type +msgid "Media Type" +msgstr "Tipo de medio" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__oauth_token +msgid "Oauth Token" +msgstr "Token de Oauth" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "PUBLISHING ON X: %(error)s" +msgstr "PUBLICANDO EN X: %(error)s" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__post_since_id +msgid "Post Since" +msgstr "Publicación desde" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__x_access_token_oauth1 +msgid "Post, like, comment, answer" +msgstr "Publicar, dar me gusta, comentar, responder" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__quote_count +#: model:ir.model.fields,field_description:social_media_x.field_social_post_account__quote_count +msgid "Quote Count" +msgstr "Número de citas" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__rate_limit_endpoint +msgid "Rate Limit Endpoint" +msgstr "Límites del endpoint" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__enable_since +msgid "" +"Read only the posts published after the last one already synchronized, to " +"consume fewer API requests. The metrics of the previous posts are no longer " +"updated while this option is on." +msgstr "" +"Lee solo las publicaciones posteriores a la última ya sincronizada, para " +"consumir menos solicitudes de la API. Mientras esta opción esté activa, las " +"métricas de las publicaciones anteriores dejan de actualizarse." + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__x_access_token_oauth2 +msgid "Read tweets" +msgstr "Leer tweets" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__last_post_ref +msgid "" +"Reference of the newest tweet read from X, used to ask only for the ones " +"published after it." +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__retweet_count +#: model:ir.model.fields,field_description:social_media_x.field_social_post_account__retweet_count +msgid "Retweet Count" +msgstr "Conteo de retuits" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_account +msgid "Social Account" +msgstr "Cuenta de red social" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_media +msgid "Social Media" +msgstr "Redes sociales" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_post +msgid "Social Post" +msgstr "Post social" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_post_account +msgid "Social Post Account" +msgstr "Post social de la cuenta" + +#. module: social_media_x +#: model_terms:ir.ui.view,arch_db:social_media_x.wizard_social_account_view_form_inherit +msgid "" +"The X API no longer has a free access tier. The developer\n" +" App must belong to a Project and be enrolled in a paid\n" +" plan, otherwise X rejects every request of this module.\n" +" See the" +msgstr "" +"La API de X ya no tiene un nivel de acceso gratuito. La App de\n" +" desarrollador debe pertenecer a un Project y estar\n" +" inscrita en un plan de pago; de lo contrario X rechaza\n" +" todas las solicitudes de este módulo. Consulta" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/controllers/social_media_x.py:0 +#, python-format +msgid "The account could not be associated. Check the server log for details." +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"The account was not created: the OAuth2 access token could not be obtained." +msgstr "No se creó la cuenta: no se pudo obtener el token de acceso OAuth2." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post.py:0 +#, python-format +msgid "" +"There are X accounts with the same username (%(username)s), please check to " +"avoid spam errors." +msgstr "" +"Hay cuentas de X con el mismo nombre de usuario (%(username)s), revíselo " +"para evitar errores de spam." + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__post_since_id +msgid "This post is updated with each request with the latest one." +msgstr "Esta publicación se actualiza con cada solicitud con la más reciente." + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_access_token_oauth2 +msgid "Token for read" +msgstr "Token de lectura" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_access_token_oauth1 +msgid "Token for write" +msgstr "Token de escritura" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "Unexpected response from X: %(error)s" +msgstr "Respuesta inesperada de X: %(error)s" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__username +msgid "Username" +msgstr "Usuario" + +#. module: social_media_x +#: model:ir.model.fields.selection,name:social_media_x.selection__social_media__media_type__x +msgid "X" +msgstr "X" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#: code:addons/social_media_x/wizards/wizard_social_account.py:0 +#: model_terms:ir.ui.view,arch_db:social_media_x.wizard_social_account_view_form_inherit +#, python-format +msgid "X API pricing" +msgstr "los precios de la API de X" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_access_secret_oauth1 +msgid "X Access Secret Oauth1" +msgstr "Secreto de acceso X Oauth1" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_post_account__x_post_url +msgid "X Post Url" +msgstr "URL del post de X" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "" +"X did not accept the post. The account may have reached the limit of " +"requests of its plan: check the account and try again later." +msgstr "" +"X no ha aceptado la publicación. Puede que la cuenta haya alcanzado el " +"límite de peticiones de su plan: revise la cuenta e inténtelo de nuevo más " +"tarde." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"X rejected the request because the developer App is not enrolled in a paid " +"plan. The X API no longer has a free access tier: attach the App to a " +"Project and subscribe a plan at %(pricing_link)s, then try again." +msgstr "" +"X ha rechazado la petición porque la App de desarrollador no está inscrita " +"en un plan de pago. La API de X ya no tiene un nivel de acceso gratuito: " +"asocie la App a un Proyecto y suscriba un plan en %(pricing_link)s, y vuelva " +"a intentarlo." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"You have reached the limit of requests %(endpoint)s allowed according " +"to your account plan.
• Total limit: %(limit)s request(s) per " +"window or period
• Remaining: %(remaining)s
• Next request: %(next_request)s
Please try again after that time (Next request)." +"
For more information, see the rate limits." +msgstr "" +"Has alcanzado el límite de solicitudes %(endpoint)s permitidas según " +"el plan de tu cuenta.
• Límite total: %(limit)s solicitud(es) por " +"ventana o periodo
• Restantes: %(remaining)s
• Siguiente " +"solicitud: %(next_request)s
Vuelve a intentarlo después de ese tiempo " +"(Siguiente solicitud).
Para obtener más información, consulta los límites de velocidad." diff --git a/social_media_x/i18n/es_ES.po b/social_media_x/i18n/es_ES.po new file mode 100644 index 0000000000..1978bd75c1 --- /dev/null +++ b/social_media_x/i18n/es_ES.po @@ -0,0 +1,345 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * social_media_x +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-03 23:00+0000\n" +"PO-Revision-Date: 2026-08-03 02:57+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_api_key +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__x_api_key +msgid "API Key" +msgstr "Clave de API" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_api_secret +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__x_api_secret +msgid "API Secret" +msgstr "Llave secreta API" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/wizards/wizard_social_account.py:0 +#, python-format +msgid "" +"Account access could not be authorized. Please check your settings or try " +"again later." +msgstr "" +"No se pudo autorizar el acceso a la cuenta. Revisa tu configuración o " +"inténtalo de nuevo más tarde." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/wizards/wizard_social_account.py:0 +#, python-format +msgid "An account with that information already exists." +msgstr "Ya existe una cuenta con esa información." + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_wizard_social_account +msgid "Associate Social Media Account" +msgstr "Asociar Cuenta de Red Social" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "ERROR DELETE POST X: %(error)s" +msgstr "ERROR AL ELIMINAR EL POST DE X: %(error)s" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__enable_since +msgid "Enable Since" +msgstr "Habilitar desde" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__engagement +msgid "Engagement" +msgstr "Engagement" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "Error Comment Tweet: %(error)s" +msgstr "Error al comentar el tweet: %(error)s" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "Error Get Comment Post: %(error)s" +msgstr "Error al obtener el comentario del post: %(error)s" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "Error Get Comments for Tweet: %(error)s" +msgstr "Error al obtener los comentarios del tweet: %(error)s" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "Error getting X access token: %(error)s" +msgstr "Error al obtener el token de acceso de X: %(error)s" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"Invalid X callback: the request token is missing. Please restart the account " +"association process." +msgstr "" +"Retorno de llamada de X no válido: falta el token de solicitud. Reinicie el " +"proceso de asociación de la cuenta." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"Invalid X request token. Please restart the account association process." +msgstr "" +"Token de solicitud de X no válido. Reinicie el proceso de asociación de la " +"cuenta." + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__last_post_ref +msgid "Last Post" +msgstr "Último post" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_media__media_type +msgid "Media Type" +msgstr "Tipo de medio" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__oauth_token +msgid "Oauth Token" +msgstr "Token de Oauth" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "PUBLISHING ON X: %(error)s" +msgstr "PUBLICANDO EN X: %(error)s" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__post_since_id +msgid "Post Since" +msgstr "Publicación desde" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__x_access_token_oauth1 +msgid "Post, like, comment, answer" +msgstr "Publicar, dar me gusta, comentar, responder" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__quote_count +#: model:ir.model.fields,field_description:social_media_x.field_social_post_account__quote_count +msgid "Quote Count" +msgstr "Número de citas" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__rate_limit_endpoint +msgid "Rate Limit Endpoint" +msgstr "Límites del endpoint" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__enable_since +msgid "" +"Read only the posts published after the last one already synchronized, to " +"consume fewer API requests. The metrics of the previous posts are no longer " +"updated while this option is on." +msgstr "" +"Lee solo las publicaciones posteriores a la última ya sincronizada, para " +"consumir menos solicitudes de la API. Mientras esta opción esté activa, las " +"métricas de las publicaciones anteriores dejan de actualizarse." + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__x_access_token_oauth2 +msgid "Read tweets" +msgstr "Leer tweets" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__last_post_ref +msgid "" +"Reference of the newest tweet read from X, used to ask only for the ones " +"published after it." +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__retweet_count +#: model:ir.model.fields,field_description:social_media_x.field_social_post_account__retweet_count +msgid "Retweet Count" +msgstr "Conteo de retuits" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_account +msgid "Social Account" +msgstr "Cuenta de red social" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_media +msgid "Social Media" +msgstr "Redes sociales" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_post +msgid "Social Post" +msgstr "Post social" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_post_account +msgid "Social Post Account" +msgstr "Post social de la cuenta" + +#. module: social_media_x +#: model_terms:ir.ui.view,arch_db:social_media_x.wizard_social_account_view_form_inherit +msgid "" +"The X API no longer has a free access tier. The developer\n" +" App must belong to a Project and be enrolled in a paid\n" +" plan, otherwise X rejects every request of this module.\n" +" See the" +msgstr "" +"La API de X ya no tiene un nivel de acceso gratuito. La App de\n" +" desarrollador debe pertenecer a un Project y estar\n" +" inscrita en un plan de pago; de lo contrario X rechaza\n" +" todas las solicitudes de este módulo. Consulta" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/controllers/social_media_x.py:0 +#, python-format +msgid "The account could not be associated. Check the server log for details." +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"The account was not created: the OAuth2 access token could not be obtained." +msgstr "No se creó la cuenta: no se pudo obtener el token de acceso OAuth2." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post.py:0 +#, python-format +msgid "" +"There are X accounts with the same username (%(username)s), please check to " +"avoid spam errors." +msgstr "" +"Hay cuentas de X con el mismo nombre de usuario (%(username)s), revíselo " +"para evitar errores de spam." + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__post_since_id +msgid "This post is updated with each request with the latest one." +msgstr "Esta publicación se actualiza con cada solicitud con la más reciente." + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_access_token_oauth2 +msgid "Token for read" +msgstr "Token de lectura" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_access_token_oauth1 +msgid "Token for write" +msgstr "Token de escritura" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "Unexpected response from X: %(error)s" +msgstr "Respuesta inesperada de X: %(error)s" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__username +msgid "Username" +msgstr "Usuario" + +#. module: social_media_x +#: model:ir.model.fields.selection,name:social_media_x.selection__social_media__media_type__x +msgid "X" +msgstr "X" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#: code:addons/social_media_x/wizards/wizard_social_account.py:0 +#: model_terms:ir.ui.view,arch_db:social_media_x.wizard_social_account_view_form_inherit +#, python-format +msgid "X API pricing" +msgstr "los precios de la API de X" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_access_secret_oauth1 +msgid "X Access Secret Oauth1" +msgstr "Secreto de acceso X Oauth1" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_post_account__x_post_url +msgid "X Post Url" +msgstr "URL del post de X" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "" +"X did not accept the post. The account may have reached the limit of " +"requests of its plan: check the account and try again later." +msgstr "" +"X no ha aceptado la publicación. Puede que la cuenta haya alcanzado el " +"límite de peticiones de su plan: revise la cuenta e inténtelo de nuevo más " +"tarde." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"X rejected the request because the developer App is not enrolled in a paid " +"plan. The X API no longer has a free access tier: attach the App to a " +"Project and subscribe a plan at %(pricing_link)s, then try again." +msgstr "" +"X ha rechazado la petición porque la App de desarrollador no está inscrita " +"en un plan de pago. La API de X ya no tiene un nivel de acceso gratuito: " +"asocie la App a un Proyecto y suscriba un plan en %(pricing_link)s, y vuelva " +"a intentarlo." + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"You have reached the limit of requests %(endpoint)s allowed according " +"to your account plan.
• Total limit: %(limit)s request(s) per " +"window or period
• Remaining: %(remaining)s
• Next request: %(next_request)s
Please try again after that time (Next request)." +"
For more information, see the rate limits." +msgstr "" +"Has alcanzado el límite de solicitudes %(endpoint)s permitidas según " +"el plan de tu cuenta.
• Límite total: %(limit)s solicitud(es) por " +"ventana o periodo
• Restantes: %(remaining)s
• Siguiente " +"solicitud: %(next_request)s
Vuelve a intentarlo después de ese tiempo " +"(Siguiente solicitud).
Para obtener más información, consulta los límites de velocidad." diff --git a/social_media_x/i18n/social_media_x.pot b/social_media_x/i18n/social_media_x.pot new file mode 100644 index 0000000000..01b1fdec10 --- /dev/null +++ b/social_media_x/i18n/social_media_x.pot @@ -0,0 +1,316 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * social_media_x +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-03 23:00+0000\n" +"PO-Revision-Date: 2026-08-03 23:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_api_key +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__x_api_key +msgid "API Key" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_api_secret +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__x_api_secret +msgid "API Secret" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/wizards/wizard_social_account.py:0 +#, python-format +msgid "" +"Account access could not be authorized. Please check your settings or try " +"again later." +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/wizards/wizard_social_account.py:0 +#, python-format +msgid "An account with that information already exists." +msgstr "" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_wizard_social_account +msgid "Associate Social Media Account" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "ERROR DELETE POST X: %(error)s" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__enable_since +msgid "Enable Since" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__engagement +msgid "Engagement" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "Error Comment Tweet: %(error)s" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "Error Get Comment Post: %(error)s" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "Error Get Comments for Tweet: %(error)s" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "Error getting X access token: %(error)s" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"Invalid X callback: the request token is missing. Please restart the account" +" association process." +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"Invalid X request token. Please restart the account association process." +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__last_post_ref +msgid "Last Post" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_media__media_type +msgid "Media Type" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__oauth_token +msgid "Oauth Token" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "PUBLISHING ON X: %(error)s" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__post_since_id +msgid "Post Since" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__x_access_token_oauth1 +msgid "Post, like, comment, answer" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__quote_count +#: model:ir.model.fields,field_description:social_media_x.field_social_post_account__quote_count +msgid "Quote Count" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__rate_limit_endpoint +msgid "Rate Limit Endpoint" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__enable_since +msgid "" +"Read only the posts published after the last one already synchronized, to " +"consume fewer API requests. The metrics of the previous posts are no longer " +"updated while this option is on." +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__x_access_token_oauth2 +msgid "Read tweets" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__last_post_ref +msgid "" +"Reference of the newest tweet read from X, used to ask only for the ones " +"published after it." +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__retweet_count +#: model:ir.model.fields,field_description:social_media_x.field_social_post_account__retweet_count +msgid "Retweet Count" +msgstr "" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_account +msgid "Social Account" +msgstr "" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_media +msgid "Social Media" +msgstr "" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_post +msgid "Social Post" +msgstr "" + +#. module: social_media_x +#: model:ir.model,name:social_media_x.model_social_post_account +msgid "Social Post Account" +msgstr "" + +#. module: social_media_x +#: model_terms:ir.ui.view,arch_db:social_media_x.wizard_social_account_view_form_inherit +msgid "" +"The X API no longer has a free access tier. The developer\n" +" App must belong to a Project and be enrolled in a paid\n" +" plan, otherwise X rejects every request of this module.\n" +" See the" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/controllers/social_media_x.py:0 +#, python-format +msgid "The account could not be associated. Check the server log for details." +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"The account was not created: the OAuth2 access token could not be obtained." +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post.py:0 +#, python-format +msgid "" +"There are X accounts with the same username (%(username)s), please check to " +"avoid spam errors." +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,help:social_media_x.field_social_account__post_since_id +msgid "This post is updated with each request with the latest one." +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_access_token_oauth2 +msgid "Token for read" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_access_token_oauth1 +msgid "Token for write" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "Unexpected response from X: %(error)s" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_wizard_social_account__username +msgid "Username" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields.selection,name:social_media_x.selection__social_media__media_type__x +msgid "X" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#: code:addons/social_media_x/wizards/wizard_social_account.py:0 +#: model_terms:ir.ui.view,arch_db:social_media_x.wizard_social_account_view_form_inherit +#, python-format +msgid "X API pricing" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_account__x_access_secret_oauth1 +msgid "X Access Secret Oauth1" +msgstr "" + +#. module: social_media_x +#: model:ir.model.fields,field_description:social_media_x.field_social_post_account__x_post_url +msgid "X Post Url" +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_post_account.py:0 +#, python-format +msgid "" +"X did not accept the post. The account may have reached the limit of " +"requests of its plan: check the account and try again later." +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"X rejected the request because the developer App is not enrolled in a paid " +"plan. The X API no longer has a free access tier: attach the App to a " +"Project and subscribe a plan at %(pricing_link)s, then try again." +msgstr "" + +#. module: social_media_x +#. odoo-python +#: code:addons/social_media_x/models/social_account.py:0 +#, python-format +msgid "" +"You have reached the limit of requests %(endpoint)s allowed according" +" to your account plan.
• Total limit: %(limit)s request(s) per " +"window or period
• Remaining: %(remaining)s
• Next " +"request: %(next_request)s
Please try again after that time (Next " +"request).
For more information, see the rate limits." +msgstr "" diff --git a/social_media_x/models/__init__.py b/social_media_x/models/__init__.py new file mode 100644 index 0000000000..fd4dcec963 --- /dev/null +++ b/social_media_x/models/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from . import social_account +from . import social_media +from . import social_post +from . import social_post_account diff --git a/social_media_x/models/social_account.py b/social_media_x/models/social_account.py new file mode 100644 index 0000000000..1001af4e1d --- /dev/null +++ b/social_media_x/models/social_account.py @@ -0,0 +1,917 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import io +import itertools +import logging +import time +from datetime import datetime + +import pytz +import requests +import tweepy +from markupsafe import Markup, escape +from tweepy.errors import Forbidden, TooManyRequests, Unauthorized + +from odoo import Command, _, api, fields, models +from odoo.exceptions import AccessError, UserError + +from odoo.addons.social_media_base.exceptions import SocialCredentialsError + +from ..social_x_utils import ( + _URL_OAUTH2_TOKEN_X, + _URL_OAUTH_X, + _URL_PRICING_X, + _URL_RATE_LIMITS_X, + _URL_X, + _get_oauth, + _is_app_without_paid_plan, +) + +_logger = logging.getLogger(__name__) + + +class SocialAccount(models.Model): + """X implementation of the social account API calls.""" + + _inherit = "social.account" + + x_access_token_oauth2 = fields.Char( + string="Token for read", help="Read tweets", groups="base.group_system" + ) + x_access_token_oauth1 = fields.Char( + string="Token for write", + help="Post, like, comment, answer", + groups="base.group_system", + ) + x_access_secret_oauth1 = fields.Char(groups="base.group_system") + x_api_key = fields.Char(string="API Key", groups="base.group_system") + x_api_secret = fields.Char(string="API Secret", groups="base.group_system") + retweet_count = fields.Integer(default=0) + quote_count = fields.Integer(default=0) + rate_limit_endpoint = fields.Json(copy=False, default=dict) + last_post_ref = fields.Char( + string="Last Post", + copy=False, + help="Reference of the newest tweet read from X, used to ask only " + "for the ones published after it.", + ) + enable_since = fields.Boolean( + default=False, + help="Read only the posts published after the last one already " + "synchronized, to consume fewer API requests. The metrics of the " + "previous posts are no longer updated while this option is on.", + ) + post_since_id = fields.Many2one( + "social.post.account", + compute="_compute_post_since_id", + store=True, + domain=[("media_type", "=", "x")], + help="This post is updated with each request with the latest one.", + ) + engagement = fields.Float( + default=0, + compute="_compute_engagement", + inverse="_inverse_engagement", + store=True, + ) + + @api.depends("interactions_count", "impression_count") + def _compute_engagement(self): + for account in self: + if account.media_type == "x": + account.engagement = ( + (account.interactions_count / account.impression_count) * 100 + if account.impression_count > 0 + else 0 + ) + else: + account.engagement = account.engagement or 0 + + def _inverse_engagement(self): + """Allow direct writes from other social media modules.""" + + @api.depends( + "retweet_count", + "quote_count", + "click_count", + "like_count", + "share_count", + "comment_count", + ) + def _compute_interactions_count(self): + res = super()._compute_interactions_count() + for account in self: + account.interactions_count = ( + account.click_count + + account.like_count + + account.share_count + + account.comment_count + + account.retweet_count + + account.quote_count + ) + return res + + @api.onchange("enable_since") + def _onchange_post_since_id(self): + for account in self: + if not account.enable_since: + account.post_since_id = False + account.last_post_ref = False + + @api.depends( + "last_post_ref", + "enable_since", + "post_account_ids.remote_ref", + "post_account_ids.published_date", + ) + def _compute_post_since_id(self): + SocialPostAccount = self.env["social.post.account"] + accounts = self.filtered("enable_since") + (self - accounts).post_since_id = False + if not accounts: + return + # The publications of the whole batch are read in a single query, + # ordered so the first one found per account is the one a search + # with ``limit=1`` would have returned. + latest_by_account = {} + latest_by_ref = {} + for post_account in SocialPostAccount.search( + [("account_id", "in", accounts.ids)], order="published_date desc" + ): + latest_by_account.setdefault(post_account.account_id.id, post_account.id) + latest_by_ref.setdefault( + (post_account.account_id.id, post_account.remote_ref), post_account.id + ) + for account in accounts: + if account.last_post_ref: + account.post_since_id = latest_by_ref.get( + (account.id, account.last_post_ref), False + ) + else: + account.post_since_id = latest_by_account.get(account.id, False) + + def _get_group_account_username(self): + """Group these accounts by username to detect duplicated X users. + + Only the X accounts already holding a username are grouped: the + constraint calling it receives every account of the post, and neither + the accounts of another media nor the ones whose username is still + empty are a duplicate of anything. + + :return: Tuples of username and number of accounts using it. + :rtype: list + """ + return self._read_group( + domain=[ + ("id", "in", self.ids), + ("media_type", "=", "x"), + ("username", "!=", False), + ], + groupby=["username"], + aggregates=["__count"], + ) + + def _fields_account_url(self): + return super()._fields_account_url() + [ + ( + "x", + f"{_URL_X}{self.username}", + ) + ] + + def _valid_time_request(self, endpoint="get_tweets"): + """Return whether the rate limit window of the endpoint is already over. + + :rtype: bool + """ + timezone = pytz.timezone(self.env.user.tz or "UTC") + now = datetime.now(timezone).replace(tzinfo=None) + limit_reset = ( + self.rate_limit_endpoint.get(endpoint, {}).get("x-rate-limit-reset", False) + if self.rate_limit_endpoint + else None + ) + if ( + limit_reset + and datetime.fromtimestamp(limit_reset, tz=timezone).replace(tzinfo=None) + >= now + ): + return self._get_message_many_requests(endpoint=endpoint) + return True + + def _get_message_many_requests( + self, ex=None, endpoint="get_tweets", view_type="kanban" + ): + """Store the rate limit headers and tell the user when to retry. + + :param ex: the TooManyRequests error carrying the headers, if any. + :return: True when no limit is known, False once the user is warned. + :rtype: bool + """ + timezone = pytz.timezone(self.env.user.tz or "UTC") + if ex: + headers = ex.response.headers + rate_limit_endpoint = dict(self.rate_limit_endpoint or {}) + rate_limit_endpoint[endpoint] = { + "x-rate-limit-limit": int(headers.get("x-rate-limit-limit", 0)), + "x-rate-limit-remaining": int(headers.get("x-rate-limit-remaining", 0)), + "x-rate-limit-reset": int( + headers.get("x-rate-limit-reset", time.time() + 60) + ), + } + self.write({"rate_limit_endpoint": rate_limit_endpoint}) + if not self.rate_limit_endpoint: + return True + limit_reset = self.rate_limit_endpoint.get(endpoint, {}).get( + "x-rate-limit-reset", 0 + ) + next_valid_request = limit_reset and datetime.fromtimestamp( + limit_reset, tz=timezone + ).replace(tzinfo=None) + message = Markup( + _( + "You have reached the limit of requests %(endpoint)s " + "allowed according to your account plan." + "
\u2022\u2009Total limit: %(limit)s request(s) per " + "window or period" + "
\u2022\u2009Remaining: %(remaining)s" + "
\u2022\u2009Next request: %(next_request)s
" + "Please try again after that time (Next request).
" + "For more information, see the " + "rate limits.", + limit=str( + escape( + self.rate_limit_endpoint.get(endpoint, {}).get( + "x-rate-limit-limit", 0 + ) + ) + ), + remaining=str( + escape( + self.rate_limit_endpoint.get(endpoint, {}).get( + "x-rate-limit-remaining", 0 + ) + ) + ), + next_request=next_valid_request, + endpoint=endpoint.replace("_", " ").capitalize(), + rate_limit_url=_URL_RATE_LIMITS_X, + ) + ) + _logger.info( + "X rate limit reached on endpoint %s for account %s, next request at %s", + endpoint, + self.name, + next_valid_request, + ) + self._notify_user_client( + notif_type=f"social_{view_type}_info", + notif_message=message, + media="X", + account_name=self.name, + ) + return False + + def _get_access_token_oauth2(self, wizard_social_account=None): + """Return the app-only bearer token used for the read endpoints.""" + account_sudo = self.sudo() + credentials = ( + f"{wizard_social_account.x_api_key or account_sudo.x_api_key}:" + f"{wizard_social_account.x_api_secret or account_sudo.x_api_secret}" + ).encode() + b64_credentials = base64.b64encode(credentials).decode("utf-8") + url = _URL_OAUTH2_TOKEN_X + headers = { + "Authorization": f"Basic {b64_credentials}", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + } + data = {"grant_type": "client_credentials"} + response = requests.post(url, headers=headers, data=data, timeout=10) + token = response.json().get("access_token", False) + return token + + @api.model + def _x_error_message(self, error, pricing_link=None): + """Explain the error of X, telling apart the missing paid plan. + + The message is rendered as HTML by the notifications, so the answer of + X is escaped: it is third party content and it must never be trusted + as markup. + + :param pricing_link: what to write in place of the link to the pricing + page. An anchor by default, so that the message can be rendered as + it is; the sinks that escape the message pass their own + placeholder instead. + :return: The message to show to the user. + :rtype: markupsafe.Markup + """ + if _is_app_without_paid_plan(error): + if pricing_link is None: + pricing_link = str( + Markup("%s") + % (_URL_PRICING_X, _("X API pricing")) + ) + return Markup( + _( + "X rejected the request because the developer App is not " + "enrolled in a paid plan. The X API no longer has a free " + "access tier: attach the App to a Project and subscribe a " + "plan at %(pricing_link)s, then try again.", + pricing_link=pricing_link, + ) + ) + return escape(str(error)) + + @api.model + def _get_x_oauth_wizard(self, kwargs): + """Return the association wizard that started this OAuth flow. + + OAuth 1.0a has no ``state`` parameter, so the request token is what + ties the callback to the flow that started it. The wizard is looked + up by that token and by its creator, so a callback cannot pick the + wizard of another user and use their API credentials. + """ + oauth_token = (kwargs or {}).get("oauth_token", False) + if not oauth_token: + raise UserError( + _( + "Invalid X callback: the request token is missing. " + "Please restart the account association process." + ) + ) + wizard_social_account = ( + self.env["wizard.social.account"] + .sudo() + .search( + [ + ("oauth_token", "=", oauth_token), + ("create_uid", "=", self.env.user.id), + ], + limit=1, + ) + ) + if not wizard_social_account: + raise UserError( + _( + "Invalid X request token. Please restart the account " + "association process." + ) + ) + return wizard_social_account + + def _get_access_token(self, kwargs): + url = f"{_URL_OAUTH_X}/access_token" + wizard_social_account = self._get_x_oauth_wizard(kwargs) + account_sudo = self.sudo() + auth = _get_oauth( + wizard_social_account.x_api_key or account_sudo.x_api_key, + wizard_social_account.x_api_secret or account_sudo.x_api_secret, + request_access_token=kwargs, + ) + oauth_verifier = kwargs.get("oauth_verifier") + response = requests.post( + url, auth=auth, data={"oauth_verifier": oauth_verifier}, timeout=10 + ) + if response.status_code != 200: + raise UserError( + _("Error getting X access token: %(error)s", error=response.text) + ) + try: + access_tokens = dict(x.split("=") for x in response.text.split("&")) + return access_tokens["oauth_token"], access_tokens["oauth_token_secret"] + except (ValueError, KeyError) as ex: + raise UserError( + _("Unexpected response from X: %(error)s", error=response.text) + ) from ex + + def get_client_api( + self, + client_api=True, + x_access_token_oauth1=None, + x_access_secret_oauth1=None, + bearer_token=None, + kwargs=None, + ): + """Build the tweepy client for this account. + + ``client_api`` returns the API v2 client used for reading and + posting; otherwise the v1.1 client, still needed to upload media. + + :rtype: tweepy.Client | tweepy.API + """ + if client_api: + wizard_social_account = self.env["wizard.social.account"] + if kwargs: + wizard_social_account = self._get_x_oauth_wizard(kwargs) + account_sudo = self.sudo() + return tweepy.Client( + bearer_token=account_sudo.x_access_token_oauth2 or bearer_token, + consumer_key=account_sudo.x_api_key or wizard_social_account.x_api_key, + consumer_secret=account_sudo.x_api_secret + or wizard_social_account.x_api_secret, + access_token=x_access_token_oauth1 + or account_sudo.x_access_token_oauth1, + access_token_secret=x_access_secret_oauth1 + or account_sudo.x_access_secret_oauth1, + ) + account_sudo = self.sudo() + auth = tweepy.OAuth1UserHandler( + consumer_key=account_sudo.x_api_key, + consumer_secret=account_sudo.x_api_secret, + access_token=x_access_token_oauth1 or account_sudo.x_access_token_oauth1, + access_token_secret=x_access_secret_oauth1 + or account_sudo.x_access_secret_oauth1, + ) + return tweepy.API(auth) + + def _update_account_data(self): + client = self.get_client_api(bearer_token=self.sudo().x_access_token_oauth2) + data = client.get_me( + user_fields=["username", "name", "profile_image_url", "created_at"] + ).data + values = { + "name": data.name, + "username": data.username, + } + media_content = requests.get(data.profile_image_url, timeout=10) + if media_content.status_code == 200: + values.update( + { + "image_1920": base64.b64encode(media_content.content), + } + ) + self.write(values) + + def create_account_x(self, x_access_token_oauth1, x_access_secret_oauth1, kwargs): + """Create or update the account of the authorized X user. + + An existing account is only reused when the current user is allowed + to associate it, and it is reactivated if it was archived. + """ + client = self.get_client_api( + x_access_token_oauth1=x_access_token_oauth1, + x_access_secret_oauth1=x_access_secret_oauth1, + kwargs=kwargs, + ) + try: + data = client.get_me( + user_fields=[ + "username", + "name", + "public_metrics", + "profile_image_url", + "created_at", + ] + ).data + if data.username: + wizard_social_account = self._get_x_oauth_wizard(kwargs) + media_content = requests.get(data.profile_image_url, timeout=10) + account_image = None + if media_content.status_code == 200: + account_image = base64.b64encode(media_content.content) + values = { + "remote_ref": data.id, + "name": data.name, + "x_api_key": wizard_social_account.x_api_key, + "x_api_secret": wizard_social_account.x_api_secret, + "username": data.username, + "image_1920": account_image, + "media_id": self.env.ref("social_media_x.social_media_x").id, + "x_access_token_oauth1": x_access_token_oauth1, + "x_access_secret_oauth1": x_access_secret_oauth1, + } + access_token_oauth2 = self._get_access_token_oauth2( + wizard_social_account + ) + if access_token_oauth2: + values.update({"x_access_token_oauth2": access_token_oauth2}) + acc_count = self._find_account_to_associate( + "x", str(data.id), username=data.username + ) + if acc_count: + acc_count._check_can_associate() + if not acc_count: + account = self.sudo().create( + dict(values, user_id=self.env.user.id) + ) + else: + if not acc_count.active: + values["active"] = True + acc_count.sudo().write(values) + account = acc_count + account._trigger_initial_sync() + else: + message_error = _( + "The account was not created: the OAuth2 access " + "token could not be obtained." + ) + self._notify_failed_association(message_error) + _logger.error( + "The X account was not created: no OAuth2 access token" + ) + except (AccessError, UserError): + raise + except TooManyRequests as exManyRequest: + self._get_message_many_requests(exManyRequest, endpoint="create_account") + except Exception as e: # noqa: BLE001 - tweepy may fail in any way + _logger.exception("Error reading the authorized X user") + self._notify_failed_association(self._x_error_message(e)) + + def _notify_failed_association(self, message_error): + """Tell the user why the account of X could not be associated. + + This runs while answering the OAuth callback, which ends in a redirect + that reloads the web client. A bus notification races with that + reload, so the message is kept in the session instead: it is the only + way of delivering it exactly once. + """ + self._notify_user_session( + self._format_user_notification(message_error, media="X") + ) + + def _prepare_medias_for_tweet( + self, image_ids=None, video_ids=None, image_datas=None + ): + media_ids = [] + if image_datas: + image_ids = [image_datas.split(",")[-1]] + api = self.get_client_api(client_api=False) + for media_post in list(itertools.chain(image_ids or [], video_ids or [])): + image_file = io.BytesIO( + base64.b64decode(media_post.datas) + if not isinstance(media_post, str) + else base64.b64decode(media_post) + ) + media = api.media_upload( + filename=(media_post.name if not isinstance(media_post, str) else False) + or False, + file=image_file, + ) + media_ids.append(media.media_id) + return media_ids + + def create_tweet(self, message, image_ids, video_ids, post_id, post_account_id): + """Publish a post with its media. + + The rate limit is answered here, because it is not a failure of the + post but a time to wait, and the user is told when to try again. + Everything else is left to the caller: the publication guard records + it on the line, with its reason, which is where the user looks for it + afterwards. An authorization refused by X is raised as a credentials + error, and since X has no way to renew the token from Odoo, the + account is flagged for the user to authorize it again. + + :return: The id of the published tweet or False. + :rtype: str | bool + """ + context = dict(self.env.context) + client_api = self.get_client_api() + try: + medias = self._prepare_medias_for_tweet( + image_ids=image_ids, video_ids=video_ids + ) + if (medias and (image_ids or video_ids)) or ( + not image_ids or not video_ids + ): + tweet = client_api.create_tweet( + text=message, media_ids=medias if len(medias) > 0 else None + ) + return tweet.data.get("id", False) + return False + except TooManyRequests as exManyRequest: + if not context.get("social_post_cron", False): + self._get_message_many_requests( + exManyRequest, endpoint="create_tweet", view_type="form" + ) + else: + post_id._message_error_post( + str(exManyRequest), post_account_id.media_type + ) + return False + except (Unauthorized, Forbidden) as error: + raise SocialCredentialsError( + _("PUBLISHING ON X: %(error)s", error=error) + ) from error + + def _get_x_statistics(self, statistics): + return list( + itertools.chain( + statistics, + self.search_read( + [("media_type", "=", "x")], + [ + "name", + "company_id", + "media_id", + "impression_count", + "interactions_count", + "engagement", + "need_update", + ], + ), + ) + ) + + def _get_users_tweets(self, since_id=None): + """Read the timeline of this account, from ``since_id`` when given.""" + client_api = self.get_client_api(bearer_token=self.sudo().x_access_token_oauth2) + return client_api.get_users_tweets( + id=self.remote_ref, + max_results=100, + tweet_fields=[ + "id", + "text", + "created_at", + "author_id", + "public_metrics", + "attachments", + "entities", + "conversation_id", + "in_reply_to_user_id", + "referenced_tweets", + ], + expansions="attachments.media_keys,author_id", + user_fields=["profile_image_url"], + media_fields=[ + "media_key", + "type", + "url", + "variants", + "public_metrics", + ], + exclude=["retweets", "replies"], + since_id=since_id, + ) + + def _get_public_metrics(self, val_x): + """Return the like, impression, reply, retweet and quote counts. + + :rtype: tuple + """ + public_metrics = val_x.public_metrics + return public_metrics.get("like_count", 0), public_metrics.get( + "impression_count", 0 + ), public_metrics.get("reply_count", 0), public_metrics.get( + "retweet_count", 0 + ), public_metrics.get("quote_count", 0) + + def _get_post_accounts_by_tweet(self, tweet_ids): + """Prefetch post accounts by tweet id to avoid per-tweet searches.""" + post_accounts_by_tweet = {} + if tweet_ids: + for existing in self.env["social.post.account"].search( + [("remote_ref", "in", tweet_ids)] + ): + post_accounts_by_tweet.setdefault(existing.remote_ref, existing) + return post_accounts_by_tweet + + def _update_posts_statistics(self, post_id, domain): + statistics = super()._update_posts_statistics(post_id, domain) + PostAccount = self.env["social.post.account"] + if not self: + account_ids = self.search( + [ + ("media_type", "=", "x"), + ] + ) + elif any(val.media_type == "x" for val in self): + account_ids = self + else: + return self._get_x_statistics(statistics) + + timezone = pytz.timezone(self.env.user.tz or "UTC") + for account in account_ids: + try: + result = account.with_context(notify_client=True)._valid_time_request() + if result: + post_accounts = [] + since_id = ( + account.post_since_id.remote_ref + if account.post_since_id + and account.post_since_id.remote_ref + and account.enable_since + else None + ) + response = account._get_users_tweets(since_id) + if not response.data: + message_error = "
".join( + [ + f"
{error.get('detail', '')}" + for error in response.errors + ] + ) + _logger.error("Get Tweets: %s", message_error) + self._notify_user_client( + notif_type="social_kanban_danger", + notif_message=message_error, + media="X", + account_name=account.name, + ) + continue + media_map = { + m.media_key: (m.media_key, m.url, m.type) + for m in (response.includes.get("media") or []) + } + post_accounts_by_tweet = account._get_post_accounts_by_tweet( + [str(val_x.id) for val_x in (response.data or []) if val_x.id] + ) + like_count = 0 + impression_count = 0 + comment_count = 0 + retweet_count = 0 + quote_count = 0 + users = { + str(u.id): u for u in (response.includes.get("users", []) or []) + } + for val_x in response.data or []: + has_quote = any( + rt.type == "quoted" + for rt in (val_x.referenced_tweets or []) + ) + is_root = (val_x.conversation_id == val_x.id) and ( + val_x.in_reply_to_user_id is None + ) + if is_root and not has_quote: + author = users.get(str(val_x.author_id)) + post_account = post_accounts_by_tweet.get( + str(val_x.id), PostAccount + ) + media_keys = (getattr(val_x, "attachments", {}) or {}).get( + "media_keys", [] + ) + image_ids = None + if media_keys: + image_ids = post_account._get_assets_save_x( + media_keys, media_map + ) + message_text = val_x.text + if val_x.entities: + for url in getattr(val_x, "entities", {}).get( + "urls", [] + ): + message_text = message_text.replace( + url.get("url", ""), "" + ) + public_metrics = account._get_public_metrics(val_x) + like_count += public_metrics[0] + impression_count += public_metrics[1] + comment_count += public_metrics[2] + retweet_count += public_metrics[3] + quote_count += public_metrics[4] + + data = { + "remote_ref": val_x.get("id"), + "post_account_url": ( + f"{_URL_X}{account.username}/status/{val_x.id}" + ), + "message": message_text, + "account_id": account.id, + "published_date": val_x.created_at.astimezone( + timezone + ).replace(tzinfo=None), + "like_count": public_metrics[0], + "impression_count": public_metrics[1], + "comment_count": public_metrics[2], + "retweet_count": public_metrics[3], + "quote_count": public_metrics[4], + "actor_urn": val_x.author_id, + "state": "posted", + "author": author.username, + } + if image_ids: + data.update({"image_ids": image_ids}) + if not post_account: + post_accounts.append(Command.create(data)) + else: + post_accounts.append( + Command.update(post_account.id, data) + ) + update_account_data = {} + if len(post_accounts) > 0: + update_account_data.update({"post_account_ids": post_accounts}) + update_account_data.update( + { + "like_count": like_count, + "impression_count": impression_count, + "retweet_count": retweet_count, + "quote_count": quote_count, + "comment_count": comment_count, + "last_post_ref": response.meta.get("newest_id", False) + if account.enable_since + else False, + } + ) + account.write(update_account_data) + + except TooManyRequests as exManyRequest: + account.with_context(notify_client=True)._get_message_many_requests( + ex=exManyRequest + ) + except Exception as e: # noqa: BLE001 - tweepy may fail in any way + _logger.exception("Error reading the posts of the X account") + self._notify_user_client( + notif_type="social_kanban_danger", + notif_message=str(e), + media="X", + account_name=account.name, + ) + return self._get_x_statistics(statistics) + + def update_account(self): + res = super().update_account() + if self.media_type == "x": + account_sudo = self.sudo() + ctx = dict(res.get("context", {})) + ctx.update( + { + "default_x_api_key": account_sudo.x_api_key, + "default_x_api_secret": account_sudo.x_api_secret, + } + ) + res["context"] = ctx + return res + + def _get_chart_account_statistics(self, start_date, end_date, granularity): + data = super()._get_chart_account_statistics(start_date, end_date, granularity) + data_x = [] + if not self: + account_ids = self.search([("media_type", "=", "x")]) + elif any(account.media_type == "x" for account in self): + account_ids = self + else: + return data + for account in account_ids: + like_count = 0 + impression_count = 0 + comment_count = 0 + retweet_count = 0 + quote_count = 0 + try: + update_account_data = {} + start_date, end_date = account._get_default_filter_date( + start_date, end_date + ) + if not account._valid_time_request(): + data_x += account._map_chart_statistics( + [ + ( + 0, + account.like_count, + account.comment_count, + account.retweet_count + account.quote_count, + account.engagement, + account.impression_count, + ) + ], + **{"start_date": start_date, "end_date": end_date}, + ) + continue + response = account._get_users_tweets() + for val_x in response.data or []: + public_metrics = account._get_public_metrics(val_x) + like_count += public_metrics[0] + impression_count += public_metrics[1] + comment_count += public_metrics[2] + retweet_count += public_metrics[3] + quote_count += public_metrics[4] + update_account_data.update( + { + "like_count": like_count, + "impression_count": impression_count, + "retweet_count": retweet_count, + "quote_count": quote_count, + "comment_count": comment_count, + "last_post_ref": response.meta.get("newest_id", False) + if account.enable_since + else False, + } + ) + account.write(update_account_data) + except TooManyRequests as exManyRequest: + account._get_message_many_requests( + exManyRequest, endpoint="chart_account", view_type="chart" + ) + except Exception as e: # noqa: BLE001 - tweepy may fail in any way + _logger.exception("Error reading the statistics of the X account") + self._notify_user_client( + notif_type="social_chart_danger", + notif_message=str(e), + media="X", + account_name=account.name, + ) + data_x += account._map_chart_statistics( + [ + ( + 0, + account.like_count, + account.comment_count, + account.retweet_count + account.quote_count, + account.engagement, + account.impression_count, + ) + ], + **{"start_date": start_date, "end_date": end_date}, + ) + return list(itertools.chain(data, data_x)) diff --git a/social_media_x/models/social_media.py b/social_media_x/models/social_media.py new file mode 100644 index 0000000000..9096fa304a --- /dev/null +++ b/social_media_x/models/social_media.py @@ -0,0 +1,26 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class SocialMedia(models.Model): + """Registers X as an available social media.""" + + _inherit = "social.media" + + media_type = fields.Selection(selection_add=[("x", "X")]) + + def open_action_account(self): + res = super().open_action_account() + if self.media_type == "x": + return { + "res_model": "wizard.social.account", + "views": [[False, "form"]], + "target": "new", + "type": "ir.actions.act_window", + "context": { + "default_media_id": self.id, + }, + } + return res diff --git a/social_media_x/models/social_post.py b/social_media_x/models/social_post.py new file mode 100644 index 0000000000..834c9d068d --- /dev/null +++ b/social_media_x/models/social_post.py @@ -0,0 +1,38 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import itertools + +from odoo import _, api, models +from odoo.exceptions import ValidationError + + +class SocialPost(models.Model): + """X specific defaults and constraints on the posts to publish.""" + + _inherit = "social.post" + + def _default_account_ids(self): + res = super()._default_account_ids() + account_ids = ( + self.env["social.account"] + .with_company(self.env.company) + .search([("media_type", "=", "x")]) + ) + if account_ids: + return list(itertools.chain(account_ids.ids, res)) + return res + + @api.constrains("account_ids", "message", "image_ids", "video_ids") + def _check_account_ids(self): + """Reject posts sent twice to the same X user, which X reads as spam.""" + for post in self: + for username, count in post.account_ids._get_group_account_username(): + if count > 1: + raise ValidationError( + _( + "There are X accounts with the same username " + "(%(username)s), please check to avoid spam errors.", + username=username, + ) + ) diff --git a/social_media_x/models/social_post_account.py b/social_media_x/models/social_post_account.py new file mode 100644 index 0000000000..2a1d6c8dec --- /dev/null +++ b/social_media_x/models/social_post_account.py @@ -0,0 +1,329 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import itertools +import logging + +from tweepy.errors import TooManyRequests + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import plaintext2html + +from ..social_x_utils import _URL_X + +_logger = logging.getLogger(__name__) + + +class SocialPostAccount(models.Model): + """Publication, comments and statistics of a post on an X account.""" + + _inherit = "social.post.account" + + x_post_url = fields.Char() + retweet_count = fields.Integer(default=0) + quote_count = fields.Integer(default=0) + + @api.depends( + "retweet_count", + "quote_count", + "click_count", + "like_count", + "share_count", + "comment_count", + ) + def _compute_interactions_count(self): + """Add the retweets and the quotes, specific to X.""" + res = super()._compute_interactions_count() + for post_account in self: + post_account.interactions_count = ( + post_account.click_count + + post_account.like_count + + post_account.share_count + + post_account.comment_count + + post_account.retweet_count + + post_account.quote_count + ) + return res + + def _action_post(self, post_id): + res = super()._action_post(post_id) + if any(account.media_type == "x" for account in post_id.account_ids): + post_accounts = post_id.filter_by_media_types(["x"]) + for post_account in post_accounts: + with post_account._publish_guard(): + post_account_id = post_account._publish_attempt( + post_account.account_id.create_tweet, + message=post_account.message, + image_ids=post_account.post_id.image_ids, + video_ids=post_account.post_id.video_ids, + post_id=post_id, + post_account_id=post_account, + ) + if post_account_id: + post_account.write( + { + "remote_ref": post_account_id, + "post_account_url": ( + f"{_URL_X}{post_account.account_id.username}" + f"/status/{post_account_id}" + ), + "state": "posted", + "published_date": fields.Datetime.now(), + "failed_description": False, + } + ) + else: + post_account.write( + { + "state": "failed", + "failed_description": plaintext2html( + _( + "X did not accept the post. The " + "account may have reached the limit " + "of requests of its plan: check the " + "account and try again later." + ) + ), + } + ) + return res + + def get_comments(self): + """Read the replies to this post. + + :return: ``success`` and the list of comments, or the error message. + :rtype: dict + """ + data = super().get_comments() + comments = [] + if "x" == self.account_id.media_type: + try: + result = self.account_id._valid_time_request(endpoint="get_comments") + if result: + client_api = self.account_id.get_client_api( + bearer_token=self.account_id.sudo().x_access_token_oauth2 + ) + query = ( + f"conversation_id:{self.remote_ref} " + f"is:reply -is:retweet -is:quote" + ) + response = client_api.search_recent_tweets( + query=query, + tweet_fields=[ + "id", + "text", + "author_id", + "created_at", + "conversation_id", + "attachments", + "in_reply_to_user_id", + ], + expansions=[ + "author_id", + "in_reply_to_user_id", + "referenced_tweets.id", + "attachments.media_keys", + "referenced_tweets.id.author_id", + ], + user_fields="id,name,username,profile_image_url", + media_fields=["media_key", "type", "url"], + ) + if response.data: + comments = [] + users = { + str(u.id): u + for u in (response.includes.get("users", []) or []) + } + media_urls = { + media.media_key: media.url + for media in (response.includes.get("media") or []) + if media.url + } + for comment in response.data or []: + author = users.get(str(comment.author_id)) + media_keys = ( + getattr(comment, "attachments", {}) or {} + ).get("media_keys", []) + comments.append( + { + "id": comment.id, + "text": comment.text, + "actor": author.name, + "published_time": comment.created_at, + "author_image": author.profile_image_url + if author.profile_image_url + else None, + "images_url": [ + media_urls[media_key] + for media_key in media_keys + if media_key in media_urls + ], + } + ) + + except TooManyRequests as exManyRequest: + self.account_id._get_message_many_requests( + exManyRequest, endpoint="get_comments" + ) + except Exception as e: # noqa: BLE001 - tweepy may fail in any way + return_message = _("Error Get Comments for Tweet: %(error)s", error=e) + _logger.exception( + "Error getting the comments of tweet %s", self.remote_ref + ) + return { + "success": False, + "message": return_message, + } + return { + "success": True, + "data": list(itertools.chain(data.get("data", []), comments)), + } + + def create_x_comment(self, post_data): + """Publish a reply to this post, with its attachments if any. + + :rtype: dict + """ + if "x" == self.account_id.media_type: + try: + result = self.account_id._valid_time_request(endpoint="create_comment") + if result: + client_api = self.account_id.get_client_api() + if post_data.get("attachment_ids", False) and post_data.get( + "body", False + ): + attachment_ids = self.env["ir.attachment"].browse( + post_data.get("attachment_ids", []) + ) + media_ids = self.account_id._prepare_medias_for_tweet( + image_ids=attachment_ids + ) + client_api.create_tweet( + text=post_data.get("body", ""), + in_reply_to_tweet_id=self.remote_ref, + media_ids=media_ids, + ) + else: + client_api.create_tweet( + text=post_data.get("body", ""), + in_reply_to_tweet_id=self.remote_ref, + ) + except TooManyRequests as exManyRequest: + self.account_id._get_message_many_requests( + exManyRequest, endpoint="create_comment" + ) + except Exception as exp: # noqa: BLE001 - tweepy may fail in any way + return_message = _("Error Comment Tweet: %(error)s", error=exp) + _logger.exception("Error replying to tweet %s", self.remote_ref) + return { + "success": False, + "message": return_message, + } + return { + "success": True, + } + + def create_comment(self, post_data, context=None): + if "x" == self.account_id.media_type: + return self.create_x_comment(post_data) + else: + return super().create_comment(post_data, context) + + def _check_remote_post_exists(self): + """Read the post on X to know whether it is still online. + + Only the ``Not Found`` answer of X is treated as a deletion. A + throttled application or any other failure means the post could not + be read, not that it is gone, so the record is left untouched. + """ + if self.account_id.media_type != "x" or not self.remote_ref: + return super()._check_remote_post_exists() + try: + if not self.account_id._valid_time_request(endpoint="get_post"): + return True + client_api = self.account_id.get_client_api( + bearer_token=self.account_id.sudo().x_access_token_oauth2 + ) + response = client_api.get_tweet(self.remote_ref, tweet_fields=["id"]) + except TooManyRequests as exManyRequest: + self.account_id._get_message_many_requests( + exManyRequest, endpoint="get_post" + ) + return True + except Exception: # noqa: BLE001 - unreachable is not deleted + _logger.exception( + "Error checking the X post %s, it is left untouched", + self.remote_ref, + ) + return True + if self._is_x_not_found(response): + self._register_remote_post_gone() + return False + if response.errors: + _logger.warning( + "X answered with errors while checking the post %(post)s, it " + "is left untouched: %(errors)s", + {"post": self.remote_ref, "errors": response.errors}, + ) + return True + + def _is_x_not_found(self, response): + """Whether X answered that the post does not exist any more. + + X reports a deleted post as a partial error carrying the + ``resource-not-found`` type instead of raising, so the answer has to + be read rather than the exception caught. + + :param response: the ``tweepy.Response`` of a tweet read. + :rtype: bool + """ + return any( + "resource-not-found" in str(error.get("type", "")) + or "Not Found" in str(error.get("title", "")) + for error in response.errors or [] + if isinstance(error, dict) + ) + + def _delete_post_account(self): + if self.media_id.media_type == "x": + message_error = "" + try: + result = self.account_id._valid_time_request(endpoint="delete_post") + if result and self.remote_ref: + client_api = self.account_id.get_client_api( + bearer_token=self.account_id.sudo().x_access_token_oauth2 + ) + response = client_api.delete_tweet(self.remote_ref) + if response.errors: + message_error = ", ".join(response.errors) + except TooManyRequests as exManyRequest: + self.account_id._get_message_many_requests( + exManyRequest, endpoint="delete_post" + ) + except Exception as e: # noqa: BLE001 - tweepy may fail in any way + message_error = _("ERROR DELETE POST X: %(error)s", error=e) + _logger.exception("Error deleting tweet %s", self.remote_ref) + if message_error: + raise UserError(message_error) + return super()._delete_post_account() + + def _get_assets_save_x(self, media_keys, media_map): + """Return the attachment commands for the media not stored yet. + + :rtype: list + """ + attachments = [] + medias_exist = self._get_medias_account(media_keys) + for media in media_keys: + if ( + media not in medias_exist + and media_map.get(media, False) + and media_map.get(media, False)[1] + ): + command = self._map_medias_account( + **{"name": media, "url": media_map.get(media, False)[1]}, + ) + if command: + attachments.append(command) + return attachments diff --git a/social_media_x/pyproject.toml b/social_media_x/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/social_media_x/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/social_media_x/readme/CONFIGURE.md b/social_media_x/readme/CONFIGURE.md new file mode 100644 index 0000000000..c537371d25 --- /dev/null +++ b/social_media_x/readme/CONFIGURE.md @@ -0,0 +1,122 @@ +To configure this module, you need to: +--------------- +Please note that you must have a developer account. + +**Important:** the X API v2 endpoints used by this module require a developer +App **attached to a Project** and a **paid plan** (Pay Per Use, or a legacy +Basic/Pro subscription), see +[about the X API](https://docs.x.com/x-api/getting-started/about-x-api). Since February 2026 the Free access tier no longer +grants general access to the API, so the account cannot be associated with a +Free-tier App. The association wizard shows this warning, and when X rejects +the request for this reason the module replaces the raw error with a message +pointing to the pricing page. Note that the authorization screen of X is still +shown with a Free-tier App: the rejection only happens afterwards, when the +module reads the authorized user, and the message is then displayed on the +Dashboard. See the ROADMAP section for details. + +The steps required for using it are defined below: + +- Go to https://developer.twitter.com/en/portal/dashboard +- Create a developer account. +- Once the account is created, go to Projects and APPS -> Default Project and select it. + + ![PROJECT_DEFAULT](../static/img/readme/PROJECT_DEFAULT.png) + +- Then scroll to the bottom of the page and press the Edit button. + + ![CONFIGURATION_ACCOUNT](../static/img/readme/CONFIGURATION_ACCOUNT.png) + +- Once on the page, in the App Permissions section, select the Read and Write and Direct Messages. + + ![APP_PERMISIONS](../static/img/readme/APP_PERMISIONS.png) + +- Go to the App Type section and select Web App, Automated App or Bot. + + ![TYPE_APP](../static/img/readme/TYPE_APP.png) + +- Then, in the Callback URI / Redirect URL section, add a new address. Here are the steps to get that URL in Odoo: + * Go to *Configuration* > *Technical* > System Parameters. + * Search for web.base.url + * Copy the base URL and concatenate it with the endpoint. + Example: + web.base.url: http://192.168.1.7:8017 + endpoint: /social_x/callback (this value is fixed) + callback_url: http://192.168.1.7:8017/social_x/callback + +- Then go to the Website URL section and add your X profile address. + + Example: https://x.com/AccountTest + + ![CALLBACK_URI_WEBSITE_URL](../static/img/readme/CALLBACK_URI_WEBSITE_URL.png) + +- Finally, go to Projects and APPS -> Default Project -> Keys and Tokens, + press the Regenerate button, and then in the window that appears, confirm + the generation of the API Key and API Key Secret values. + + ![KEYS_AND_TOKENS](../static/img/readme/KEYS_AND_TOKENS.png) + + ![GENERATE_NEW_KEY_SECRET](../static/img/readme/GENERATE_NEW_KEY_SECRET.png) + +Learn more at [X Developer Portal](https://developer.twitter.com) + +Registering the API Key and API Key Secret. Integration of a user account. +--------------- + +- Go to *Social Media* > Configuration > Social medias +- Click on the *Associate Account* button for the desired social media. + + ![ASSOCIATE_ACCOUNT](../static/img/readme/ASSOCIATE_ACCOUNT.png) + +- A wizard will open for you to add the API Key and API Key Secret obtained + from your developer account. + + ![WIZARD_ASSOCIATE_ACCOUNT](../static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png) + +- By clicking the *Associate* button here, you'll be taken to a X authentication page. + Once you validate your information, you'll be taken to the system and the Dashboard view, + where you'll see your posts. + + ![AUTHORIZE_ACCOUNT](../static/img/readme/AUTHORIZE_ACCOUNT.png) + +- Once you have completed these steps and everything is working correctly, + you can see your account in *Social Media* > Configuration > Accounts +- After the account is associated, an initial synchronization of its posts + and statistics is triggered automatically. +- Note that the account credentials (API Key, API Secret and tokens) are only + visible to users with administration rights (*Settings*). The account form + shows the API Key in read-only mode and the API Secret masked; the tokens + are never displayed. +- The authorization flow is bound to the session that started it: the request + token returned by X only works for the user who opened the wizard. A + callback without a request token, or with the request token of another + user, is refused without creating or modifying anything; the user gets the + generic notice *The account could not be associated. Check the server log + for details.* and the exact reason is kept in the server log, so the raw + answer of the provider is never exposed. +- Each X account has to be associated with its own pair of credentials: if an + account already exists (even archived) with the same *API Key* and *API + Secret*, the association is refused with *An account with that information + already exists.* Create a different developer App for that account. +- Besides the OAuth 1.0a authorization of the user, the module obtains an + application-only *bearer token* (OAuth 2.0 *client credentials*) from the + same API Key and API Secret; that is the one used to read publications, + comments and statistics, see + [about the X API](https://docs.x.com/x-api/getting-started/about-x-api). If X + does not deliver it, the account is not created and the notice *The account + was not created: the OAuth2 access token could not be obtained.* is shown. + In the same way, if X does not answer the access token correctly when + closing the authorization, the account is not created and the error returned + by X is shown. +- Publishing with images or video uses the + [media upload](https://docs.x.com/x-api/media/upload-media) endpoint of the + X API v1.1 besides the v2 endpoints. The App has to have access to both: on + a plan without that access, the publications with attachments fail even + though the text-only ones work. +- The X account being authorized has to be active and not protected, and the + App needs read and write permission. Odoo does not check it beforehand: if X + refuses, the error returned by X is displayed on the Dashboard and the + account is not associated. +- If the X account is already linked to an account of another Odoo user, the + association is refused and nothing is overwritten: only the responsible + user of the account and the *Social Media / Administrator* group can + relink it. diff --git a/social_media_x/readme/CONTRIBUTORS.md b/social_media_x/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..4c895949c9 --- /dev/null +++ b/social_media_x/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Binhex](https://www.binhex.cloud): + - Edilio Escalona Almira diff --git a/social_media_x/readme/DESCRIPTION.md b/social_media_x/readme/DESCRIPTION.md new file mode 100644 index 0000000000..0bc87ec939 --- /dev/null +++ b/social_media_x/readme/DESCRIPTION.md @@ -0,0 +1,46 @@ +This module provides the necessary functionality for +basic interaction with the X social media. + +Main features: +- User account integration. +- Post creation. +- Comments on the publications (read and reply). +- Reports and graphs with agnostic metrics. + +Liking a publication or a comment is not available on X: the *like* and +*follow* endpoints were +[removed by X](https://devcommunity.x.com/t/update-to-x-api-free-tier-removal-of-like-and-follow-endpoints/247646), +so the button is not shown for this media on any plan. + + +Statistics account +------------------- +In the case of X statistics, only current posts are taken into account; if +some are deleted, the metrics also decrease, that is, it is not a +history of the account's posts. + +The metrics are computed on the **100 most recent publications** X answers in +a single request, and only on original publications: retweets, replies and +quotes are discarded both when importing and when computing. The publications +older than those 100 are not counted, because the +[timeline](https://docs.x.com/x-api/posts/recent-search) is not paginated, in +order to spare the requests of the plan. + +1. The eye icon: Total number of views, which may include multiple views by the same user. +2. The hand icon: means the interactions (likes, comments, retweets and quotes) + of current posts. They are the + [metrics](https://docs.x.com/x-api/fundamentals/metrics) X returns for a post. +3. The star icon: means the value of the engagement of the publications, it is a calculation + similar to (interactions / impressions) * 100. + + ![STATISTICS_ACCOUNT](../static/img/readme/STATISTICS_ACCOUNT.png) + +If the X API [rate limit](https://docs.x.com/x-api/fundamentals/rate-limits) is +reached while refreshing the statistics, the charts reuse the last metrics +stored on the account instead of failing. + +The charts of an X account always show the totals of its current publications: +the date range and the granularity do not filter the data, because the +[metrics](https://docs.x.com/x-api/fundamentals/metrics) X returns are the +current ones of each post and not a history per day. They only change the way +the data is drawn. diff --git a/social_media_x/readme/INSTALL.md b/social_media_x/readme/INSTALL.md new file mode 100644 index 0000000000..2f3e77fda0 --- /dev/null +++ b/social_media_x/readme/INSTALL.md @@ -0,0 +1,10 @@ +This module requires the following Python libraries: + +- ``tweepy`` 4.0 or above, because the module uses the client of the + [X API v2](https://docs.x.com/x-api/getting-started/about-x-api) + (``tweepy.Client``) besides the v1.1 client for the media upload. +- ``requests_oauthlib`` + +You can install them with: + + pip install tweepy requests_oauthlib diff --git a/social_media_x/readme/ROADMAP.md b/social_media_x/readme/ROADMAP.md new file mode 100644 index 0000000000..00df2c7e09 --- /dev/null +++ b/social_media_x/readme/ROADMAP.md @@ -0,0 +1,31 @@ +- Since February 2026, X deprecated the Free access tier: it no longer grants + general access to the X API v2 endpoints used by this module (reading posts, + publishing, comments, statistics). API calls made with a Free-tier account + return ``403 Forbidden``, either with ``"reason": "client-not-enrolled"`` or + asking for an App attached to a Project, since only a paid App can belong to + one. A paid plan is therefore required to use this module: the default + **Pay Per Use** plan, or the legacy Basic/Pro plans for existing + subscribers. + + ![FREE_PLAN_DEPRECATED](../static/img/readme/FREE_PLAN_DEPRECATED.png) + + * X API pricing and plans: https://docs.x.com/x-api/getting-started/pricing + * Removal of the like and follow endpoints: https://devcommunity.x.com/t/update-to-x-api-free-tier-removal-of-like-and-follow-endpoints/247646 + * Rate limits according to plan: https://docs.x.com/x-api/fundamentals/rate-limits + * How to get access to the API: https://docs.x.com/x-api/getting-started/getting-access + +- Liking a publication or a comment is not supported: X removed the *like* and + *follow* endpoints, so the button is hidden for the X publications whatever + the plan is, not only on the free tier. + +- Also keep in mind that if you want to post the same content on the same accounts (username X), + the API will not allow it for spam reasons: see the + [creation of a post](https://docs.x.com/x-api/posts/creation-of-a-post). + To avoid that rejection, Odoo does not let a post that selects two X + accounts with the same *username* be saved: the error *There are X accounts + with the same username (...), please check to avoid spam errors.* is shown. + +- The X accounts do not take part in the automatic check for updates that runs + every 30 minutes: their statistics are refreshed when the account is linked, + with the *Update* button of the dashboard and with the monthly + synchronization. This is on purpose, to spare the requests of the API plan. diff --git a/social_media_x/readme/USAGE.md b/social_media_x/readme/USAGE.md new file mode 100644 index 0000000000..b2b57735ab --- /dev/null +++ b/social_media_x/readme/USAGE.md @@ -0,0 +1,156 @@ +List of posts generated from Odoo. +--------------- + +Only posts generated using Odoo are displayed. + +- Go to *Social Media* > Post + +Generate a post. +--------------- + +This feature acts as a template for generating multiple posts +from a single view, depending on the selected accounts. + +- Go to *Social Media* > Post > New or Go to *Social Media* > Dashboard > Add Post +- Fill in the required fields +- When the post is created, every X account of the active company is selected + by default in *Accounts*; review the list and remove the ones you do not want + to use before saving. + ![CREATE_POST](../static/img/readme/CREATE_POST.png) +- Save +- Click on the *Post* button + +Update token, API Key, API Secret and account data +--------------- + +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Click on the *Update account* button + + ![BUTTON_UPDATE_ACCOUNT](../static/img/readme/BUTTON_UPDATE_ACCOUNT.png) + +- In the wizard that appears, if none of the checkboxes are selected and the + *Update* button is pressed, the system will update only the account's data. +- If the *Update keys* checkbox is selected, the current API Key and API Secret + values will be displayed by default. Modify any of these values and authentication + will be performed again through X to update these values and the token. + + ![UPDATE_KEYS](../static/img/readme/UPDATE_KEYS.png) + +- Selecting the *Update token* checkbox will update the current token. + + ![UPDATE_TOKEN](../static/img/readme/UPDATE_TOKEN.png) + + +Archive Account X +---------------------------- +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Click on the *Archive account* button + + ![ARCHIVE_ACCOUNT](../static/img/readme/ARCHIVE_ACCOUNT.png) + +- Please note that all data associated with this account will be archived. +- To use an archived account again, open it in *Social Media* > Configuration > + Accounts (*Archived* filter) and press *Update account*: the account and its + data are reactivated instead of creating a duplicate. The *Associate + Account* wizard is not the way to do it when the same API Key and API Secret + are reused, because it refuses the keys already registered on another + account, archived ones included. +- An archived account can be deleted permanently with the *Delete + permanently* button, only available to a social media administrator. The X + publications stay online, only the Odoo history is removed. + +Enable since +------------------------ +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Select *Enable since* +- The *Post since* field is then enabled, allowing you to + select the post to start the search for in the next post + retrieval. Note that metrics for older posts will not be updated + if this option is selected. + + ![ENABLE_SINCE](../static/img/readme/ENABLE_SINCE.png) + +X limits and validations +------------------------ + +The module does not enforce the content limits of X: they are applied by X +when the publication is sent, so the failure is only seen at that moment. + +- Odoo does not check the length of the message: X limits a publication to + 280 characters (more if the account has X Premium), see the + [creation of a post](https://docs.x.com/x-api/posts/creation-of-a-post). If + it is exceeded, the line is left as *Failed* with the error returned by X. +- X accepts at most **4 images or 1 video** per publication and does not allow + mixing images and video in the same message, see the + [media upload](https://docs.x.com/x-api/media/upload-media) documentation. + Odoo does not validate it: if the post does not respect it, X refuses the + publication and the reason is kept on the failed line. +- The size of the files is not checked either: the limits of X apply + (about 5 MB per image, and 512 MB / 140 seconds per video). A bigger file + makes the upload fail and the publication is left as *Failed*. +- The images of the post are limited to JPG/JPEG and PNG, and the videos to + MP4. That is the filter of the file picker, not a server validation: a file + attached another way is refused by X instead. + +Rate limits +------------------------ + +- The [rate limit](https://docs.x.com/x-api/fundamentals/rate-limits) is + tracked per endpoint (reading publications, publishing, comments, deletion + and charts). When X answers that it is exhausted, Odoo stores the window it + returns and does not call that endpoint again until it expires: a notice is + shown with the limit of the plan, the remaining requests and the time of the + next attempt. If X does not say when the window resets, 60 seconds are + assumed. +- If X refuses a publication because the requests of the plan are exhausted, + the line is left as *Failed* with the message *X did not accept the post. + The account may have reached the limit of requests of its plan: check the + account and try again later.* It is not a content error: wait for the window + to end and press *Post* again, which only sends the failed accounts. + +Comments and dashboard actions +------------------------ + +- The comments of a publication are read with the + [recent search](https://docs.x.com/x-api/posts/recent-search) endpoint of X, + which only covers the **last 7 days**: the replies to older publications are + not shown on the dashboard even though the post has them. Retweets and + quotes are excluded as well, only the replies are listed. +- Before acting on a publication from the dashboard, Odoo checks on X that the + post still exists. If it was deleted directly on X, the action stops showing + the error returned by X. +- Deleting a publication from the dashboard deletes the post on X first. If X + refuses the deletion, the operation is cancelled with the error returned by + X and the publication keeps existing both on X and in Odoo. +- The direct calls to X made while linking the account (token requests and + download of the profile image) have a timeout of 10 seconds. If the network + or X take longer, the association fails and has to be repeated. + +X credentials +------------------------ + +The [OAuth 1.0a](https://docs.x.com/resources/fundamentals/authentication/oauth-1-0a/api-key-and-secret) +tokens of X do not expire, so there is nothing to renew: they +only stop working when the access is revoked from the X application or the +keys are changed. When that happens X refuses the publication, the reason is +kept on the failed publication and the account is marked as needing an +update. Odoo cannot renew it by itself: associate the account again from +*Update account*. + +Uninstalling the module +------------------------ + +Uninstalling *Social Media X* does not delete the accounts nor their +publication history: + +- The access tokens are cleared, so no credential outlives the module. +- The X accounts are archived, together with their posts. +- The X specific data is lost, because Odoo drops the columns of an + uninstalled module: the API Key, the API Secret and the OAuth 1 tokens. +- The identifier of each account and publication on X is kept, so installing + the module back and associating the account again reactivates the archived + history and updates it, instead of importing everything as duplicated + records. diff --git a/social_media_x/social_x_utils.py b/social_media_x/social_x_utils.py new file mode 100644 index 0000000000..d3c97b5a70 --- /dev/null +++ b/social_media_x/social_x_utils.py @@ -0,0 +1,39 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from requests_oauthlib import OAuth1 + +_URL_X = "https://x.com/" +_URL_OAUTH_X = "https://api.twitter.com/oauth" +_URL_OAUTH2_TOKEN_X = "https://api.twitter.com/oauth2/token" +_URL_RATE_LIMITS_X = "https://docs.x.com/x-api/fundamentals/rate-limits" +_URL_PRICING_X = "https://docs.x.com/x-api/getting-started/pricing" +_NO_PAID_PLAN_REASONS_X = ( + "client-not-enrolled", + "attached to a project", +) + + +def _is_app_without_paid_plan(error): + """Whether X rejected the request because the App has no paid plan. + + X has no single answer for it: some endpoints answer ``403`` with + ``"reason": "client-not-enrolled"``, while the API v2 ones answer ``403`` + asking for an App attached to a Project. Both mean the same since the + free tier was removed, as only a paid App can belong to a Project. + + :rtype: bool + """ + message = str(error).lower() + return any(reason in message for reason in _NO_PAID_PLAN_REASONS_X) + + +def _get_oauth(api_key, api_secret, request_access_token=False): + if request_access_token: + return OAuth1( + api_key, + api_secret, + request_access_token.get("oauth_token"), + request_access_token.get("oauth_token_secret"), + ) + return OAuth1(api_key, api_secret) diff --git a/social_media_x/static/description/icon.png b/social_media_x/static/description/icon.png new file mode 100644 index 0000000000..7e19b411d6 Binary files /dev/null and b/social_media_x/static/description/icon.png differ diff --git a/social_media_x/static/description/index.html b/social_media_x/static/description/index.html new file mode 100644 index 0000000000..3c3b80fc49 --- /dev/null +++ b/social_media_x/static/description/index.html @@ -0,0 +1,849 @@ + + + + + +Social Media X + + + +
+

Social Media X

+ + +

Beta License: AGPL-3 OCA/social Translate me on Weblate Try me on Runboat

+

This module provides the necessary functionality for basic interaction +with the X social media.

+

Main features:

+
    +
  • User account integration.
  • +
  • Post creation.
  • +
  • Comments on the publications (read and reply).
  • +
  • Reports and graphs with agnostic metrics.
  • +
+

Liking a publication or a comment is not available on X: the like and +follow endpoints were removed by +X, +so the button is not shown for this media on any plan.

+
+

Statistics account

+

In the case of X statistics, only current posts are taken into account; +if some are deleted, the metrics also decrease, that is, it is not a +history of the account’s posts.

+

The metrics are computed on the 100 most recent publications X +answers in a single request, and only on original publications: +retweets, replies and quotes are discarded both when importing and when +computing. The publications older than those 100 are not counted, +because the timeline +is not paginated, in order to spare the requests of the plan.

+
    +
  1. The eye icon: Total number of views, which may include multiple views +by the same user.

    +
  2. +
  3. The hand icon: means the interactions (likes, comments, retweets and +quotes) of current posts. They are the +metrics X returns +for a post.

    +
  4. +
  5. The star icon: means the value of the engagement of the publications, +it is a calculation similar to (interactions / impressions) * 100.

    +

    STATISTICS_ACCOUNT

    +
  6. +
+

If the X API rate +limit is reached +while refreshing the statistics, the charts reuse the last metrics +stored on the account instead of failing.

+

The charts of an X account always show the totals of its current +publications: the date range and the granularity do not filter the data, +because the metrics +X returns are the current ones of each post and not a history per day. +They only change the way the data is drawn.

+

Table of contents

+ +
+

Installation

+

This module requires the following Python libraries:

+
    +
  • tweepy 4.0 or above, because the module uses the client of the X +API v2 +(tweepy.Client) besides the v1.1 client for the media upload.
  • +
  • requests_oauthlib
  • +
+

You can install them with:

+
+pip install tweepy requests_oauthlib
+
+
+ +
+
+

To configure this module, you need to:

+

Please note that you must have a developer account.

+

Important: the X API v2 endpoints used by this module require a +developer App attached to a Project and a paid plan (Pay Per +Use, or a legacy Basic/Pro subscription), see about the X +API. Since +February 2026 the Free access tier no longer grants general access to +the API, so the account cannot be associated with a Free-tier App. The +association wizard shows this warning, and when X rejects the request +for this reason the module replaces the raw error with a message +pointing to the pricing page. Note that the authorization screen of X is +still shown with a Free-tier App: the rejection only happens afterwards, +when the module reads the authorized user, and the message is then +displayed on the Dashboard. See the ROADMAP section for details.

+

The steps required for using it are defined below:

+
    +
  • Go to https://developer.twitter.com/en/portal/dashboard

    +
  • +
  • Create a developer account.

    +
  • +
  • Once the account is created, go to Projects and APPS -> Default +Project and select it.

    +

    PROJECT_DEFAULT

    +
  • +
  • Then scroll to the bottom of the page and press the Edit button.

    +

    CONFIGURATION_ACCOUNT

    +
  • +
  • Once on the page, in the App Permissions section, select the Read and +Write and Direct Messages.

    +

    APP_PERMISIONS

    +
  • +
  • Go to the App Type section and select Web App, Automated App or Bot.

    +

    TYPE_APP

    +
  • +
  • Then, in the Callback URI / Redirect URL section, add a new address. +Here are the steps to get that URL in Odoo:

    + +
  • +
  • Then go to the Website URL section and add your X profile address.

    +

    Example: https://x.com/AccountTest

    +

    CALLBACK_URI_WEBSITE_URL

    +
  • +
  • Finally, go to Projects and APPS -> Default Project -> Keys and +Tokens, press the Regenerate button, and then in the window that +appears, confirm the generation of the API Key and API Key Secret +values.

    +

    KEYS_AND_TOKENS

    +

    GENERATE_NEW_KEY_SECRET

    +
  • +
+

Learn more at X Developer Portal

+
+
+

Registering the API Key and API Key Secret. Integration of a user account.

+
    +
  • Go to Social Media > Configuration > Social medias

    +
  • +
  • Click on the Associate Account button for the desired social media.

    +

    ASSOCIATE_ACCOUNT

    +
  • +
  • A wizard will open for you to add the API Key and API Key Secret +obtained from your developer account.

    +

    WIZARD_ASSOCIATE_ACCOUNT

    +
  • +
  • By clicking the Associate button here, you’ll be taken to a X +authentication page. Once you validate your information, you’ll be +taken to the system and the Dashboard view, where you’ll see your +posts.

    +

    AUTHORIZE_ACCOUNT

    +
  • +
  • Once you have completed these steps and everything is working +correctly, you can see your account in Social Media > Configuration +> Accounts

    +
  • +
  • After the account is associated, an initial synchronization of its +posts and statistics is triggered automatically.

    +
  • +
  • Note that the account credentials (API Key, API Secret and tokens) are +only visible to users with administration rights (Settings). The +account form shows the API Key in read-only mode and the API Secret +masked; the tokens are never displayed.

    +
  • +
  • The authorization flow is bound to the session that started it: the +request token returned by X only works for the user who opened the +wizard. A callback without a request token, or with the request token +of another user, is refused without creating or modifying anything; +the user gets the generic notice The account could not be associated. +Check the server log for details. and the exact reason is kept in the +server log, so the raw answer of the provider is never exposed.

    +
  • +
  • Each X account has to be associated with its own pair of credentials: +if an account already exists (even archived) with the same API Key +and API Secret, the association is refused with An account with +that information already exists. Create a different developer App for +that account.

    +
  • +
  • Besides the OAuth 1.0a authorization of the user, the module obtains +an application-only bearer token (OAuth 2.0 client credentials) +from the same API Key and API Secret; that is the one used to read +publications, comments and statistics, see about the X +API. If X +does not deliver it, the account is not created and the notice The +account was not created: the OAuth2 access token could not be +obtained. is shown. In the same way, if X does not answer the access +token correctly when closing the authorization, the account is not +created and the error returned by X is shown.

    +
  • +
  • Publishing with images or video uses the media +upload endpoint of +the X API v1.1 besides the v2 endpoints. The App has to have access to +both: on a plan without that access, the publications with attachments +fail even though the text-only ones work.

    +
  • +
  • The X account being authorized has to be active and not protected, and +the App needs read and write permission. Odoo does not check it +beforehand: if X refuses, the error returned by X is displayed on the +Dashboard and the account is not associated.

    +
  • +
  • If the X account is already linked to an account of another Odoo user, +the association is refused and nothing is overwritten: only the +responsible user of the account and the Social Media / Administrator +group can relink it.

    +
  • +
+
+

Usage

+
+
+
+

List of posts generated from Odoo.

+

Only posts generated using Odoo are displayed.

+
    +
  • Go to Social Media > Post
  • +
+
+
+

Generate a post.

+

This feature acts as a template for generating multiple posts from a +single view, depending on the selected accounts.

+
    +
  • Go to Social Media > Post > New or Go to Social Media > Dashboard +> Add Post
  • +
  • Fill in the required fields
  • +
  • When the post is created, every X account of the active company is +selected by default in Accounts; review the list and remove the ones +you do not want to use before saving. CREATE_POST
  • +
  • Save
  • +
  • Click on the Post button
  • +
+
+
+

Update token, API Key, API Secret and account data

+
    +
  • Go to Social Media > Configuration > Accounts

    +
  • +
  • Select the account

    +
  • +
  • Click on the Update account button

    +

    BUTTON_UPDATE_ACCOUNT

    +
  • +
  • In the wizard that appears, if none of the checkboxes are selected and +the Update button is pressed, the system will update only the +account’s data.

    +
  • +
  • If the Update keys checkbox is selected, the current API Key and API +Secret values will be displayed by default. Modify any of these values +and authentication will be performed again through X to update these +values and the token.

    +

    UPDATE_KEYS

    +
  • +
  • Selecting the Update token checkbox will update the current token.

    +

    UPDATE_TOKEN

    +
  • +
+
+
+

Archive Account X

+
    +
  • Go to Social Media > Configuration > Accounts

    +
  • +
  • Select the account

    +
  • +
  • Click on the Archive account button

    +

    ARCHIVE_ACCOUNT

    +
  • +
  • Please note that all data associated with this account will be +archived.

    +
  • +
  • To use an archived account again, open it in Social Media > +Configuration > Accounts (Archived filter) and press Update +account: the account and its data are reactivated instead of creating +a duplicate. The Associate Account wizard is not the way to do it +when the same API Key and API Secret are reused, because it refuses +the keys already registered on another account, archived ones +included.

    +
  • +
  • An archived account can be deleted permanently with the Delete +permanently button, only available to a social media administrator. +The X publications stay online, only the Odoo history is removed.

    +
  • +
+
+
+

Enable since

+
    +
  • Go to Social Media > Configuration > Accounts

    +
  • +
  • Select the account

    +
  • +
  • Select Enable since

    +
  • +
  • The Post since field is then enabled, allowing you to select the +post to start the search for in the next post retrieval. Note that +metrics for older posts will not be updated if this option is +selected.

    +

    ENABLE_SINCE

    +
  • +
+
+
+

X limits and validations

+

The module does not enforce the content limits of X: they are applied by +X when the publication is sent, so the failure is only seen at that +moment.

+
    +
  • Odoo does not check the length of the message: X limits a publication +to 280 characters (more if the account has X Premium), see the +creation of a +post. If it is +exceeded, the line is left as Failed with the error returned by X.
  • +
  • X accepts at most 4 images or 1 video per publication and does not +allow mixing images and video in the same message, see the media +upload documentation. +Odoo does not validate it: if the post does not respect it, X refuses +the publication and the reason is kept on the failed line.
  • +
  • The size of the files is not checked either: the limits of X apply +(about 5 MB per image, and 512 MB / 140 seconds per video). A bigger +file makes the upload fail and the publication is left as Failed.
  • +
  • The images of the post are limited to JPG/JPEG and PNG, and the videos +to MP4. That is the filter of the file picker, not a server +validation: a file attached another way is refused by X instead.
  • +
+
+
+

Rate limits

+
    +
  • The rate limit +is tracked per endpoint (reading publications, publishing, comments, +deletion and charts). When X answers that it is exhausted, Odoo stores +the window it returns and does not call that endpoint again until it +expires: a notice is shown with the limit of the plan, the remaining +requests and the time of the next attempt. If X does not say when the +window resets, 60 seconds are assumed.
  • +
  • If X refuses a publication because the requests of the plan are +exhausted, the line is left as Failed with the message X did not +accept the post. The account may have reached the limit of requests of +its plan: check the account and try again later. It is not a content +error: wait for the window to end and press Post again, which only +sends the failed accounts.
  • +
+
+
+

Comments and dashboard actions

+
    +
  • The comments of a publication are read with the recent +search endpoint of +X, which only covers the last 7 days: the replies to older +publications are not shown on the dashboard even though the post has +them. Retweets and quotes are excluded as well, only the replies are +listed.
  • +
  • Before acting on a publication from the dashboard, Odoo checks on X +that the post still exists. If it was deleted directly on X, the +action stops showing the error returned by X.
  • +
  • Deleting a publication from the dashboard deletes the post on X first. +If X refuses the deletion, the operation is cancelled with the error +returned by X and the publication keeps existing both on X and in +Odoo.
  • +
  • The direct calls to X made while linking the account (token requests +and download of the profile image) have a timeout of 10 seconds. If +the network or X take longer, the association fails and has to be +repeated.
  • +
+
+
+

X credentials

+

The OAuth +1.0a +tokens of X do not expire, so there is nothing to renew: they only stop +working when the access is revoked from the X application or the keys +are changed. When that happens X refuses the publication, the reason is +kept on the failed publication and the account is marked as needing an +update. Odoo cannot renew it by itself: associate the account again from +Update account.

+
+
+

Uninstalling the module

+

Uninstalling Social Media X does not delete the accounts nor their +publication history:

+
    +
  • The access tokens are cleared, so no credential outlives the module.
  • +
  • The X accounts are archived, together with their posts.
  • +
  • The X specific data is lost, because Odoo drops the columns of an +uninstalled module: the API Key, the API Secret and the OAuth 1 +tokens.
  • +
  • The identifier of each account and publication on X is kept, so +installing the module back and associating the account again +reactivates the archived history and updates it, instead of importing +everything as duplicated records.
  • +
+
+

Known issues / Roadmap

+
    +
  • Since February 2026, X deprecated the Free access tier: it no longer +grants general access to the X API v2 endpoints used by this module +(reading posts, publishing, comments, statistics). API calls made with +a Free-tier account return 403 Forbidden, either with +"reason": "client-not-enrolled" or asking for an App attached to a +Project, since only a paid App can belong to one. A paid plan is +therefore required to use this module: the default Pay Per Use +plan, or the legacy Basic/Pro plans for existing subscribers.

    +

    FREE_PLAN_DEPRECATED

    + +
  • +
  • Liking a publication or a comment is not supported: X removed the +like and follow endpoints, so the button is hidden for the X +publications whatever the plan is, not only on the free tier.

    +
  • +
  • Also keep in mind that if you want to post the same content on the +same accounts (username X), the API will not allow it for spam +reasons: see the creation of a +post. To avoid +that rejection, Odoo does not let a post that selects two X accounts +with the same username be saved: the error There are X accounts +with the same username (…), please check to avoid spam errors. is +shown.

    +
  • +
  • The X accounts do not take part in the automatic check for updates +that runs every 30 minutes: their statistics are refreshed when the +account is linked, with the Update button of the dashboard and with +the monthly synchronization. This is on purpose, to spare the requests +of the API plan.

    +
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

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

+
+
+

Credits

+
+
+
+

Authors

+
    +
  • BinhexTeam
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

edescalona

+

This module is part of the OCA/social project on GitHub.

+

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

+
+
+ + diff --git a/social_media_x/static/img/readme/APP_PERMISIONS.png b/social_media_x/static/img/readme/APP_PERMISIONS.png new file mode 100644 index 0000000000..10f4d77292 Binary files /dev/null and b/social_media_x/static/img/readme/APP_PERMISIONS.png differ diff --git a/social_media_x/static/img/readme/ARCHIVE_ACCOUNT.png b/social_media_x/static/img/readme/ARCHIVE_ACCOUNT.png new file mode 100644 index 0000000000..b5a1a22df8 Binary files /dev/null and b/social_media_x/static/img/readme/ARCHIVE_ACCOUNT.png differ diff --git a/social_media_x/static/img/readme/ASSOCIATE_ACCOUNT.png b/social_media_x/static/img/readme/ASSOCIATE_ACCOUNT.png new file mode 100644 index 0000000000..270a952d81 Binary files /dev/null and b/social_media_x/static/img/readme/ASSOCIATE_ACCOUNT.png differ diff --git a/social_media_x/static/img/readme/AUTHORIZE_ACCOUNT.png b/social_media_x/static/img/readme/AUTHORIZE_ACCOUNT.png new file mode 100644 index 0000000000..79ea898cd4 Binary files /dev/null and b/social_media_x/static/img/readme/AUTHORIZE_ACCOUNT.png differ diff --git a/social_media_x/static/img/readme/BUTTON_UPDATE_ACCOUNT.png b/social_media_x/static/img/readme/BUTTON_UPDATE_ACCOUNT.png new file mode 100644 index 0000000000..b365d3bd4d Binary files /dev/null and b/social_media_x/static/img/readme/BUTTON_UPDATE_ACCOUNT.png differ diff --git a/social_media_x/static/img/readme/CALLBACK_URI_WEBSITE_URL.png b/social_media_x/static/img/readme/CALLBACK_URI_WEBSITE_URL.png new file mode 100644 index 0000000000..b0843c0abd Binary files /dev/null and b/social_media_x/static/img/readme/CALLBACK_URI_WEBSITE_URL.png differ diff --git a/social_media_x/static/img/readme/CONFIGURATION_ACCOUNT.png b/social_media_x/static/img/readme/CONFIGURATION_ACCOUNT.png new file mode 100644 index 0000000000..f815b6da2a Binary files /dev/null and b/social_media_x/static/img/readme/CONFIGURATION_ACCOUNT.png differ diff --git a/social_media_x/static/img/readme/CREATE_POST.png b/social_media_x/static/img/readme/CREATE_POST.png new file mode 100644 index 0000000000..054783ae20 Binary files /dev/null and b/social_media_x/static/img/readme/CREATE_POST.png differ diff --git a/social_media_x/static/img/readme/ENABLE_SINCE.png b/social_media_x/static/img/readme/ENABLE_SINCE.png new file mode 100644 index 0000000000..b0626ac5a4 Binary files /dev/null and b/social_media_x/static/img/readme/ENABLE_SINCE.png differ diff --git a/social_media_x/static/img/readme/FREE_PLAN_DEPRECATED.png b/social_media_x/static/img/readme/FREE_PLAN_DEPRECATED.png new file mode 100644 index 0000000000..d3b39060c4 Binary files /dev/null and b/social_media_x/static/img/readme/FREE_PLAN_DEPRECATED.png differ diff --git a/social_media_x/static/img/readme/GENERATE_NEW_KEY_SECRET.png b/social_media_x/static/img/readme/GENERATE_NEW_KEY_SECRET.png new file mode 100644 index 0000000000..905cf764b4 Binary files /dev/null and b/social_media_x/static/img/readme/GENERATE_NEW_KEY_SECRET.png differ diff --git a/social_media_x/static/img/readme/KEYS_AND_TOKENS.png b/social_media_x/static/img/readme/KEYS_AND_TOKENS.png new file mode 100644 index 0000000000..68c2ffda0a Binary files /dev/null and b/social_media_x/static/img/readme/KEYS_AND_TOKENS.png differ diff --git a/social_media_x/static/img/readme/PROJECT_DEFAULT.png b/social_media_x/static/img/readme/PROJECT_DEFAULT.png new file mode 100644 index 0000000000..88f7c42436 Binary files /dev/null and b/social_media_x/static/img/readme/PROJECT_DEFAULT.png differ diff --git a/social_media_x/static/img/readme/STATISTICS_ACCOUNT.png b/social_media_x/static/img/readme/STATISTICS_ACCOUNT.png new file mode 100644 index 0000000000..14529a6374 Binary files /dev/null and b/social_media_x/static/img/readme/STATISTICS_ACCOUNT.png differ diff --git a/social_media_x/static/img/readme/TYPE_APP.png b/social_media_x/static/img/readme/TYPE_APP.png new file mode 100644 index 0000000000..715c9e1568 Binary files /dev/null and b/social_media_x/static/img/readme/TYPE_APP.png differ diff --git a/social_media_x/static/img/readme/UPDATE_KEYS.png b/social_media_x/static/img/readme/UPDATE_KEYS.png new file mode 100644 index 0000000000..7fca829a2a Binary files /dev/null and b/social_media_x/static/img/readme/UPDATE_KEYS.png differ diff --git a/social_media_x/static/img/readme/UPDATE_TOKEN.png b/social_media_x/static/img/readme/UPDATE_TOKEN.png new file mode 100644 index 0000000000..6de96443bf Binary files /dev/null and b/social_media_x/static/img/readme/UPDATE_TOKEN.png differ diff --git a/social_media_x/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png b/social_media_x/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png new file mode 100644 index 0000000000..9e066a2498 Binary files /dev/null and b/social_media_x/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png differ diff --git a/social_media_x/static/img/x.png b/social_media_x/static/img/x.png new file mode 100644 index 0000000000..da9f0355ce Binary files /dev/null and b/social_media_x/static/img/x.png differ diff --git a/social_media_x/static/src/js/views/kanban/network_post_account_kanban_record.esm.js b/social_media_x/static/src/js/views/kanban/network_post_account_kanban_record.esm.js new file mode 100644 index 0000000000..00ce93ad35 --- /dev/null +++ b/social_media_x/static/src/js/views/kanban/network_post_account_kanban_record.esm.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ +import {SocialKanbanRecord} from "@social_media_base/js/views/kanban/social_kanban_record.esm"; +import {patch} from "@web/core/utils/patch"; + +patch(SocialKanbanRecord.prototype, { + /** @override */ + setup() { + super.setup(); + this.record.notAvailableLike = [...(this.record.notAvailableLike || []), "x"]; + }, +}); diff --git a/social_media_x/tests/__init__.py b/social_media_x/tests/__init__.py new file mode 100644 index 0000000000..d9e3c59e1c --- /dev/null +++ b/social_media_x/tests/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_common_x +from . import test_account_x +from . import test_post_x +from . import test_post_account_x +from . import test_media_x +from . import test_controller diff --git a/social_media_x/tests/test_account_x.py b/social_media_x/tests/test_account_x.py new file mode 100644 index 0000000000..3c5be6f09c --- /dev/null +++ b/social_media_x/tests/test_account_x.py @@ -0,0 +1,1298 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch + +import pytz +from tweepy.errors import Unauthorized + +from odoo import Command +from odoo.exceptions import UserError +from odoo.tools import mute_logger + +from odoo.addons.social_media_base.exceptions import SocialCredentialsError +from odoo.addons.social_media_base.tests.test_social_common import ( + PATCH_ACCOUNT, + PATCH_WIZARD_ACCOUNT, +) + +from ..models.social_account import SocialAccount as SocialAccountXCls +from ..social_x_utils import _URL_PRICING_X +from ..wizards.wizard_social_account import ( + WizardSocialAccount as WizardSocialAccountXCls, +) +from .test_common_x import ( + PATCH_ACCOUNT_X, + PATCH_REQUEST_POST, + PATCH_WIZARD_ACCOUNT_X, + TestSocialCommonX, +) + +LOGGER_ACCOUNT_X = "odoo.addons.social_media_x.models.social_account" + + +class _FakeResponse: + def __init__(self, headers): + self.headers = headers + + +class _FakeException: + def __init__(self, headers): + self.response = _FakeResponse(headers) + + +class TestSocialAccountX(TestSocialCommonX): + @classmethod + def setUpClass(cls): + super().setUpClass() + + @staticmethod + def _b64(key, secret): + return base64.b64encode(f"{key}:{secret}".encode()).decode("utf-8") + + @patch(PATCH_REQUEST_POST) + def test_wizard_credentials_when_provided(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = {"access_token": "abc123"} + mock_post.return_value = mock_resp + wizard = SimpleNamespace(x_api_key="WZ_KEY", x_api_secret="WZ_SECRET") + token = self.SocialAccountX._get_access_token_oauth2( + wizard_social_account=wizard + ) + self.assertEqual(token, "abc123") + expected_headers = { + "Authorization": f"Basic {self._b64('WZ_KEY', 'WZ_SECRET')}", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + } + mock_post.assert_called_once_with( + "https://api.twitter.com/oauth2/token", + headers=expected_headers, + data={"grant_type": "client_credentials"}, + timeout=10, + ) + + @patch(PATCH_REQUEST_POST) + def test_credentials_when_wizard_missing(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = {"access_token": "xyz789"} + mock_post.return_value = mock_resp + wizard = SimpleNamespace(x_api_key=None, x_api_secret=None) + token = self.SocialAccountCredentialX._get_access_token_oauth2( + wizard_social_account=wizard + ) + self.assertEqual(token, "xyz789") + args, kwargs = mock_post.call_args + self.assertEqual( + kwargs["headers"]["Authorization"], + f"Basic {self._b64('TEST_KEY', 'TEST_SECRET')}", + ) + self.assertEqual( + kwargs["headers"]["Content-Type"], + "application/x-www-form-urlencoded;charset=UTF-8", + ) + self.assertEqual(kwargs["data"], {"grant_type": "client_credentials"}) + self.assertEqual(kwargs["timeout"], 10) + + @patch(PATCH_REQUEST_POST) + @patch(PATCH_ACCOUNT_X.format("_get_oauth")) + def test_credentials_when_wizard_matches(self, mock_get_oauth, mock_post): + self.WizardAccountX.write({"oauth_token": "wiz-token-123"}) + kwargs = { + "oauth_token": "wiz-token-123", + "oauth_verifier": "verif-xyz", + } + mock_post.return_value.status_code = 200 + mock_post.return_value.text = ( + "oauth_token=tok123&oauth_token_secret=sec456&user_id=1&screen_name=foo" + ) + fake_auth = object() + mock_get_oauth.return_value = fake_auth + token, secret = self.SocialAccountCredentialX._get_access_token(kwargs) + self.assertEqual(token, "tok123") + self.assertEqual(secret, "sec456") + mock_get_oauth.assert_called_once_with( + "TEST_KEY", "TEST_SECRET", request_access_token=kwargs + ) + mock_post.assert_called_once_with( + "https://api.twitter.com/oauth/access_token", + auth=fake_auth, + data={"oauth_verifier": "verif-xyz"}, + timeout=10, + ) + + @patch(PATCH_REQUEST_POST) + @patch(PATCH_ACCOUNT_X.format("_get_oauth")) + def test_rejects_request_token_without_wizard(self, mock_get_oauth, mock_post): + kwargs = { + "oauth_token": "no-match-token", + "oauth_verifier": "verif-abc", + } + with self.assertRaises(UserError): + self.SocialAccountCredentialX._get_access_token(kwargs) + mock_get_oauth.assert_not_called() + mock_post.assert_not_called() + + def test_rejects_callback_without_request_token(self): + with self.assertRaises(UserError): + self.SocialAccountCredentialX._get_access_token({"oauth_verifier": "v"}) + + def test_rejects_request_token_of_another_user(self): + self.WizardAccountX.write({"oauth_token": "wiz-token-123"}) + other_user = self.env["res.users"].create( + { + "name": "Other social user", + "login": "other_social_user_x_test", + "groups_id": [ + ( + 6, + 0, + [ + self.env.ref("base.group_user").id, + self.env.ref( + "social_media_base.group_social_media_user" + ).id, + ], + ) + ], + } + ) + with self.assertRaises(UserError): + self.SocialAccountCredentialX.with_user(other_user)._get_access_token( + {"oauth_token": "wiz-token-123", "oauth_verifier": "v"} + ) + + @patch(PATCH_ACCOUNT_X.format("tweepy.Client")) + def test_client_mode_wizard_when_has_no_keys(self, mock_tweepy_client): + self.WizardAccountX.write( + { + "oauth_token": "wiztok-1", + } + ) + result = self.SocialAccountEmptyX.get_client_api( + client_api=True, + bearer_token="BT_PARAM", + kwargs={"oauth_token": "wiztok-1"}, + ) + self.assertIs(result, mock_tweepy_client.return_value) + mock_tweepy_client.assert_called_once_with( + bearer_token="BT_PARAM", + consumer_key="TEST_KEY", + consumer_secret="TEST_SECRET", + access_token=False, + access_token_secret=False, + ) + + @patch(PATCH_ACCOUNT_X.format("tweepy.Client")) + def test_client_mode_prefers_self_over_wizard(self, mock_tweepy_client): + account = self.SocialAccount.create( + { + "name": "Twitter2", + "x_api_key": "SELF_KEY", + "x_api_secret": "SELF_SECRET", + "x_access_token_oauth2": "BT_SELF", + "x_access_token_oauth1": "AT_SELF", + "x_access_secret_oauth1": "AS_SELF", + } + ) + self.WizardAccountX.write( + { + "oauth_token": "wiztok-2", + } + ) + result = account.get_client_api( + client_api=True, + bearer_token="BT_PARAM_SHOULD_BE_IGNORED", + kwargs={"oauth_token": "wiztok-2"}, + ) + self.assertIs(result, mock_tweepy_client.return_value) + mock_tweepy_client.assert_called_once_with( + bearer_token="BT_SELF", + consumer_key="SELF_KEY", + consumer_secret="SELF_SECRET", + access_token="AT_SELF", + access_token_secret="AS_SELF", + ) + + @patch(PATCH_ACCOUNT_X.format("tweepy.API")) + @patch(PATCH_ACCOUNT_X.format("tweepy.OAuth1UserHandler")) + def test_non_client_mode_uses_oauth1_flow(self, mock_oauth1_handler, mock_api): + account = self.SocialAccount.create( + { + "name": "Twitter3", + "x_api_key": "SELF_KEY", + "x_api_secret": "SELF_SECRET", + "x_access_token_oauth1": "AT_SELF", + "x_access_secret_oauth1": "AS_SELF", + } + ) + result = account.get_client_api( + client_api=False, + x_access_token_oauth1=None, + x_access_secret_oauth1=None, + ) + mock_oauth1_handler.assert_called_once_with( + consumer_key="SELF_KEY", + consumer_secret="SELF_SECRET", + access_token="AT_SELF", + access_token_secret="AS_SELF", + ) + mock_api.assert_called_once_with(mock_oauth1_handler.return_value) + self.assertIs(result, mock_api.return_value) + + def _fake_api(self, captured): + class _FakeAPI: + def __init__(self, cap): + self.cap = cap + self.counter = 0 + + def media_upload(self, filename, file): + data = file.read() + self.cap.append((filename, data)) + self.counter += 1 + return type("FakeMedia", (), {"media_id": 100 + self.counter}) + + return _FakeAPI(captured) + + def test_handles_empty_list(self): + captured_calls = [] + fake_api = self._fake_api(captured_calls) + with patch.object( + type(self.SocialAccount), "get_client_api", return_value=fake_api + ) as mock_get: + media_ids = self.SocialAccount._prepare_medias_for_tweet( + image_ids=[], video_ids=[] + ) + self.assertEqual(media_ids, []) + self.assertEqual(captured_calls, []) + _, kwargs = mock_get.call_args + self.assertEqual(kwargs, {"client_api": False}) + + def _patch_super(self, record, return_value): + return self.get_patch_super_x( + record, + WizardSocialAccountXCls, + "_action_valid_add_account", + return_value=return_value, + ) + + def test_media_type_x_account(self): + with self._patch_super( + self.WizardAccountX, return_value="SUPER_OK" + ) as mock_super: + with self.assertRaises(UserError): + self.WizardAccountX._action_valid_add_account() + mock_super.assert_called_once_with() + + self.WizardAccountX.write( + { + "x_api_key": "TEST_FAKE_KEY", + "x_api_secret": "TEST_FAKE_SECRET", + } + ) + with self._patch_super(self.WizardAccountX, return_value=False) as mock_super: + res = self.WizardAccountX._action_valid_add_account() + mock_super.assert_called_once_with() + self.assertFalse(res) + + with self._patch_super(self.WizardAccountX, return_value=True) as mock_super: + res = self.WizardAccountX._action_valid_add_account() + mock_super.assert_called_once_with() + self.assertTrue(res) + + def _patch_super_update(self, record, return_action): + return self.get_patch_super_x( + record, + SocialAccountXCls, + "update_account", + return_value=return_action, + ) + + def test_media_type_x_existing_context(self): + super_action = { + "type": "ir.actions.act_window", + "context": { + "keep": True, + "another": 1, + "default_x_api_key": "DEF_TEST_KEY", + "default_x_api_secret": "DEF_TEST_SECRET", + }, + } + with self._patch_super_update( + self.SocialAccountCredentialX, super_action + ) as mock_super: + res = self.SocialAccountCredentialX.update_account() + mock_super.assert_called_once_with() + self.assertIsInstance(res, dict) + self.assertIn("context", res) + self.assertTrue(res["context"].get("keep")) + self.assertEqual(res["context"].get("another"), 1) + self.assertEqual(res["context"]["default_x_api_key"], "TEST_KEY") + self.assertEqual(res["context"]["default_x_api_secret"], "TEST_SECRET") + + @patch(PATCH_REQUEST_POST) + @patch(PATCH_WIZARD_ACCOUNT_X.format("_get_oauth")) + def test_success_returns_act_url_and_sets_token(self, mock_get_oauth, mock_post): + mock_get_oauth.return_value = object() + mock_post.return_value = Mock( + status_code=200, text="oauth_token=AAA&oauth_token_secret=BBB" + ) + res = self.WizardAccountX._get_url_authorize() + mock_get_oauth.assert_called_once_with("TEST_KEY", "TEST_SECRET") + mock_post.assert_called_once_with( + "https://api.twitter.com/oauth/request_token", + auth=mock_get_oauth.return_value, + timeout=10, + ) + self.assertIsInstance(res, dict) + self.assertEqual(res.get("type"), "ir.actions.act_url") + self.assertEqual(res.get("target"), "self") + self.assertIn("url", res) + self.assertTrue( + res["url"].startswith("https://api.twitter.com/oauth/authorize?") + ) + self.assertIn("oauth_token=AAA", res["url"]) + self.assertEqual(self.WizardAccountX.oauth_token, "AAA") + + @patch(PATCH_WIZARD_ACCOUNT_X.format("_logger")) + @patch(PATCH_REQUEST_POST) + @patch(PATCH_WIZARD_ACCOUNT_X.format("_get_oauth")) + def test_bad_response_returns_notification( + self, mock_get_oauth, mock_post, mock_logger + ): + mock_get_oauth.return_value = object() + mock_post.return_value = Mock(text="oauth_token") + res = self.WizardAccountX._get_url_authorize() + mock_logger.error.assert_called() + self.assertEqual(res.get("type"), "ir.actions.client") + self.assertEqual(res.get("tag"), "display_notification") + self.assertEqual(res.get("target"), "new") + params = res.get("params", {}) + self.assertEqual(params.get("type"), "danger") + self.assertFalse(params.get("sticky")) + self.assertEqual(params.get("next"), {"type": "ir.actions.act_window_close"}) + self.assertTrue(params.get("message")) + + @patch(PATCH_WIZARD_ACCOUNT_X.format("_logger")) + @patch(PATCH_REQUEST_POST) + @patch(PATCH_WIZARD_ACCOUNT_X.format("_get_oauth")) + def test_not_enrolled_app_explains_the_paid_plan( + self, mock_get_oauth, mock_post, mock_logger + ): + mock_get_oauth.return_value = object() + mock_post.return_value = Mock( + status_code=403, + text='{"errors":[{"reason":"client-not-enrolled"}]}', + ) + res = self.WizardAccountX._get_url_authorize() + params = res.get("params", {}) + self.assertIn("not enrolled in a paid plan", params.get("message", "")) + self.assertEqual( + params.get("links"), + [{"url": _URL_PRICING_X, "label": "X API pricing"}], + ) + + def test_app_not_attached_to_a_project_explains_the_paid_plan(self): + """The API v2 endpoints do not answer ``client-not-enrolled``.""" + error = Exception( + "403 Forbidden\nWhen authenticating requests to the X API v2 " + "endpoints, you must use keys and tokens from a developer App " + "that is attached to a Project. You can create a project via the " + "developer portal." + ) + message = self.SocialAccount._x_error_message(error) + self.assertIn("not enrolled in a paid plan", message) + self.assertIn(_URL_PRICING_X, message) + + def test_x_error_message_keeps_other_errors(self): + self.assertEqual( + self.SocialAccount._x_error_message(ValueError("Some other error")), + "Some other error", + ) + + def test_x_error_message_escapes_the_answer_of_x(self): + """The answer of X is third party content and it is not markup.""" + message = self.SocialAccount._x_error_message( + ValueError("") + ) + self.assertNotIn("