diff --git a/.pylintrc b/.pylintrc index f3d017a8f5..a7aec2a055 100644 --- a/.pylintrc +++ b/.pylintrc @@ -92,20 +92,11 @@ enable=anomalous-backslash-in-string, no-write-in-compute, # messages that do not cause the lint step to fail consider-merging-classes-inherited, - create-user-wo-reset-password, - dangerous-filter-wo-user, deprecated-module, - file-not-used, invalid-commit, - missing-manifest-dependency, - missing-newline-extrafiles, missing-readme, - no-utf8-coding-comment, odoo-addons-relative-import, - old-api7-method-defined, redefined-builtin, - too-complex, - unnecessary-utf8-coding-comment, manifest-external-assets diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..67beb572fe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +tweepy diff --git a/social_media_x/README.rst b/social_media_x/README.rst new file mode 100644 index 0000000000..5e440e95ea --- /dev/null +++ b/social_media_x/README.rst @@ -0,0 +1,317 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============== +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/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/19.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-19-0/social-19-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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides the necessary functionality for basic interaction +with the X social network. + +Main features: + +- User account integration. +- Post creation. +- Post reactions (likes, comments). +- Comment reactions (likes) +- Reports and graphs with agnostic metrics. + +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. + +1. The eye icon: Total number of views, which may include multiple views + by the same user. + +2. The hand icon: means the impressions (likes, comments, shares, + retweets, quote_count) of current posts. + +3. The star icon: means the value of the engagement of the publications, + it is a calculation similar to interactions / (impressions \* 100). + + |STATISTICS_ACCOUNT| + +.. |STATISTICS_ACCOUNT| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/STATISTICS_ACCOUNT.png + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure this module, you need to: +-------------------------------------- + +Please note that you must have a developer account. 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) linkedin_url: + http://192.168.1.7:8017/social_x/callback + + |PROJECT_DEFAULT| + +- Then go to the Website URL section and add your X profile address. + + Example: https://x.com/AccountTest + + |PROJECT_DEFAULT| + +- 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* > Settings > Social Media + +- Click on the *Associate Account* button for the desired social + network. + + |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 + +.. |PROJECT_DEFAULT| image:: https://raw.githubusercontent.com/social_media_x/static/img/readme/PROJECT_DEFAULT.png +.. |CONFIGURATION_ACCOUNT| image:: https://raw.githubusercontent.com/social_media_x/static/img/readme/CONFIGURATION_ACCOUNT.png +.. |APP_PERMISIONS| image:: https://raw.githubusercontent.com/social_media_x/static/img/readme/APP_PERMISIONS.png +.. |TYPE_APP| image:: https://raw.githubusercontent.com/social_media_x/static/img/readme/TYPE_APP.png +.. |KEYS_AND_TOKENS| image:: https://raw.githubusercontent.com/social_media_x/static/img/readme/KEYS_AND_TOKENS.png +.. |GENERATE_NEW_KEY_SECRET| image:: https://raw.githubusercontent.com/social_media_x/static/img/readme/GENERATE_NEW_KEY_SECRET.png +.. |ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_x/static/img/readme/ASSOCIATE_ACCOUNT.png +.. |WIZARD_ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_x/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png +.. |AUTHORIZE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/19.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 |CREATE_POST| +- Save +- Click on the *Post* button + +Update token, client ID, client Secret and organization 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 + organization's data. + +- If the *Update keys* checkbox is selected, the current Client ID and + Client Secret values will be displayed by default. Modify any of these + values and authentication will be performed again through LinkedIn 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 *Delete account* button + + |ARCHIVE_ACCOUNT| + +- Please note that all data associated with this account will be + archived. + +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| + +.. |CREATE_POST| image:: https://raw.githubusercontent.com/social_media_x/static/img/readme/CREATE_POST.png +.. |BUTTON_UPDATE_ACCOUNT| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/BUTTON_UPDATE_ACCOUNT.png +.. |UPDATE_KEYS| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/UPDATE_KEYS.png +.. |UPDATE_TOKEN| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/UPDATE_TOKEN.png +.. |ARCHIVE_ACCOUNT| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/ARCHIVE_ACCOUNT.png +.. |ENABLE_SINCE| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/ENABLE_SINCE.png + +Known issues / Roadmap +====================== + +- The free X developer account has significant limitations on post + retrievals, writing, comments, likes, etc. + + - For more information, visit this link: + https://developer.x.com/en/portal/products + - The action of giving like or followers is not enabled in the free + version: + 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 + - Frequently asked: https://developer.x.com/en/support/x-api/v2 + +- 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. + +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] (https://www.binhex.cloud): + + - Edilio Escalona Almira e.escalona@binhex.cloud + +- [Trobz] (https://trobz.com/): + + - Khanh (Dinh Van) dinhvankhanhfit@gmail.com + +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. + +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..78f25bc47f --- /dev/null +++ b/social_media_x/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from . import controllers +from . import models +from . import wizards diff --git a/social_media_x/__manifest__.py b/social_media_x/__manifest__.py new file mode 100644 index 0000000000..20931704af --- /dev/null +++ b/social_media_x/__manifest__.py @@ -0,0 +1,33 @@ +# 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 network.""", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "author": "BinhexTeam,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/social", + "depends": [ + "social_media_base", + ], + "data": [ + "data/social_media_data.xml", + "views/social_account_views.xml", + "wizards/wizard_social_account.xml", + ], + "assets": { + "web.assets_backend": [ + # SERVICES + "social_media_x/static/src/js/services/**/*.js", + # KANBAN + "social_media_x/static/src/js/views/**/*.js", + ], + }, + "external_dependencies": { + "python": [ + "tweepy", + ], + }, + "exclude": ["social"], +} 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..2769513ee3 --- /dev/null +++ b/social_media_x/controllers/social_media_x.py @@ -0,0 +1,26 @@ +import logging + +from odoo import http +from odoo.http import request, route + +_logger = logging.getLogger(__name__) + + +class SocialMediaX(http.Controller): + @route( + ["/social_x/callback"], + type="http", + auth="user", + ) + def social_x(self, **kwargs): + try: + access_token, access_token_secret = ( + request.env["social.account"].sudo()._get_access_token(kwargs) + ) + if access_token and access_token_secret: + request.env["social.account"].create_account_x( + access_token, access_token_secret, kwargs + ) + except Exception as e: + _logger.error(f"Error creating X account: {e}") + return request.redirect("/web") 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..c2123407d6 --- /dev/null +++ b/social_media_x/data/social_media_data.xml @@ -0,0 +1,14 @@ + + + + X + x + Manage your X account and schedule posts + + + + 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..c690c8c8a6 --- /dev/null +++ b/social_media_x/models/social_account.py @@ -0,0 +1,673 @@ +# 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 tweepy.errors import TooManyRequests + +from odoo import api, fields, models + +from ..social_x_utils import _get_oauth + +_logger = logging.getLogger(__name__) + + +class SocialAccount(models.Model): + _inherit = "social.account" + + x_access_token_oauth2 = fields.Char(string="Token for read", help="Read tweets") + x_access_token_oauth1 = fields.Char( + string="Token for write", help="Post, like, comment, answer" + ) + x_access_secret_oauth1 = fields.Char() + x_api_key = fields.Char(string="API Key") + x_api_secret = fields.Char(string="API Secret") + x_account_id = fields.Char() + retweet_count = fields.Integer(default=0) + quote_count = fields.Integer(default=0) + rate_limit_endpoint = fields.Json(copy=False, default=dict) + last_post_id = fields.Char() + enable_since = fields.Boolean( + default=False, + help="""This field defines the post from which to + start filtering in the next search. This is useful + considering the limitations of the API in its FREE version. + It also has the disadvantage that metrics for previous posts + will not be updated if this option is selected. + """, + ) + 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", store=True) + + @api.depends("interactions_count", "impression_count") + def _compute_engagement(self): + for account in self: + if account.media_type == "x": + account.engagement = ( + round( + account.interactions_count / (account.impression_count * 100), 2 + ) + if account.impression_count + else 0 + ) + + @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_id = False + + @api.depends("last_post_id") + def _compute_post_since_id(self): + SocialPostAccount = self.env["social.post.account"] + for account in self: + account.post_since_id = SocialPostAccount.search( + [ + ("account_id", "=", account.id), + ("x_post_account_id", "=", account.last_post_id), + ], + limit=1, + ).id + + def _get_group_account_username(self): + result = self._read_group( + domain=[("id", "in", self.ids)], + groupby=["username"], + aggregates=["__count"], + ) + + return [ + {"username": username, "username_count": count} + for username, count in result + ] + + def _fields_account_url(self): + return super()._fields_account_url() + [ + ( + "x_account_id", + f"https://x.com/{self.username}", + ) + ] + + def _valid_time_request(self, endpoint="get_tweets"): + 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" + ): + 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] = { + # Limit of requests per window + "x-rate-limit-limit": int(headers.get("x-rate-limit-limit", 0)), + # Remaining amount for the current window + "x-rate-limit-remaining": int(headers.get("x-rate-limit-remaining", 0)), + # Date the window is reset and the x-rate-limit-limit is restored + "x-rate-limit-reset": int( + headers.get("x-rate-limit-reset", time.time() + 60) + ), + } + self.write({"rate_limit_endpoint": rate_limit_endpoint}) + 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 = self.env._( + """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=self.rate_limit_endpoint.get(endpoint, {}).get( + "x-rate-limit-limit", 0 + ), + remaining=self.rate_limit_endpoint.get(endpoint, {}).get( + "x-rate-limit-remaining", 0 + ), + next_request=next_valid_request, + endpoint=endpoint.replace("_", " ").capitalize(), + rate_limit_url="https://docs.x.com/x-api/fundamentals/rate-limits", + ) + _logger.info(message) + 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): + credentials = ( + f"{wizard_social_account.x_api_key or self.x_api_key}:" + f"{wizard_social_account.x_api_secret or self.x_api_secret}" + ).encode() + b64_credentials = base64.b64encode(credentials).decode("utf-8") + url = "https://api.twitter.com/oauth2/token" + 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 + + def _get_access_token(self, kwargs): + url = "https://api.twitter.com/oauth/access_token" + wizard_social_account = ( + self.env["wizard.social.account"] + .sudo() + .search_fetch( + [("oauth_token", "=", kwargs.get("oauth_token", False))], + ["x_api_key", "x_api_secret"], + limit=1, + ) + ) + auth = _get_oauth( + wizard_social_account.x_api_key or self.x_api_key, + wizard_social_account.x_api_secret or self.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 + ) + access_tokens = dict(x.split("=") for x in response.text.split("&")) + access_token = access_tokens["oauth_token"] + access_token_secret = access_tokens["oauth_token_secret"] + return access_token, access_token_secret + + def get_client_api( + self, + client_api=True, + x_access_token_oauth1=None, + x_access_secret_oauth1=None, + bearer_token=None, + kwargs=None, + ): + if client_api: + wizard_social_account = None + if kwargs: + wizard_social_account = ( + self.env["wizard.social.account"] + .sudo() + .search_fetch( + [("oauth_token", "=", kwargs.get("oauth_token", False))], + ["x_api_key", "x_api_secret"], + limit=1, + ) + ) + return tweepy.Client( + bearer_token=self.x_access_token_oauth2 or bearer_token, + consumer_key=self.x_api_key or wizard_social_account.x_api_key, + consumer_secret=self.x_api_secret or wizard_social_account.x_api_secret, + access_token=x_access_token_oauth1 or self.x_access_token_oauth1, + access_token_secret=self.x_access_secret_oauth1 + or x_access_secret_oauth1 + or self.x_access_secret_oauth1, + ) + auth = tweepy.OAuth1UserHandler( + consumer_key=self.x_api_key, + consumer_secret=self.x_api_secret, + access_token=x_access_token_oauth1 or self.x_access_token_oauth1, + access_token_secret=x_access_secret_oauth1 or self.x_access_secret_oauth1, + ) + return tweepy.API(auth) + + def _update_account_data(self): + client = self.get_client_api(bearer_token=self.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): + 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 + x_account_ids = list( + map( + lambda x: x["x_account_id"], + self.env["social.account"] + .sudo() + .search_read([("media_type", "=", "x")], ["x_account_id"]), + ) + ) + if data.id not in x_account_ids: + wizard_social_account = ( + self.env["wizard.social.account"] + .sudo() + .search_fetch( + [("oauth_token", "=", kwargs.get("oauth_token", False))], + ["x_api_key", "x_api_secret"], + limit=1, + ) + ) + media_content = requests.get(data.profile_image_url, timeout=10) + account_image = False + if media_content.status_code == 200: + account_image = base64.b64encode(media_content.content) + values = { + "x_account_id": 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.env["social.account"] + .sudo() + .search_count( + [ + ("media_type", "=", "x"), + ("x_api_key", "=", wizard_social_account.x_api_key), + ( + "x_api_secret", + "=", + wizard_social_account.x_api_secret, + ), + ] + ) + ) + if acc_count == 0: + self.create(values) + else: + self.write(values) + else: + message_error = "Not create account: Not get access token OAuth2" + + self._notify_user_client( + notif_type="social_kanban_danger", + notif_message=message_error, + media="X", + account_name=self.name, + ) + _logger.error(message_error) + except TooManyRequests as exManyRequest: + self._get_message_many_requests(exManyRequest, endpoint="create_account") + except Exception as e: + message_error = f"Request Client Me: {e}" + _logger.error(message_error) + self._notify_user_client( + notif_type="social_kanban_danger", + notif_message=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 image in image_ids or []: + image_file = io.BytesIO( + base64.b64decode(image.datas) + if not isinstance(image, str) + else base64.b64decode(image) + ) + media = api.media_upload( + filename=(image.name if not isinstance(image, str) else False) or False, + file=image_file, + ) + media_ids.append(media.media_id) + for video in video_ids or []: + video_file = io.BytesIO( + base64.b64decode(video.datas) + if not isinstance(video, str) + else base64.b64decode(video) + ) + media = api.media_upload( + filename=(video.name if not isinstance(video, str) else False) or False, + file=video_file, + ) + media_ids.append(media.media_id) + return media_ids + + def create_tweet(self, message, image_ids, video_ids): + client_api = self.get_client_api() + try: + medias = self._prepare_medias_for_tweet( + image_ids=image_ids, video_ids=video_ids + ) + tweet = client_api.create_tweet( + text=message, media_ids=medias if len(medias) > 0 else None + ) + return tweet.data.get("id", False) + except TooManyRequests as exManyRequest: + return self._get_message_many_requests( + exManyRequest, endpoint="create_tweet", view_type="form" + ) + except Exception as e: + _logger.error(f"Error Request Client Tweet: {e}") + self._notify_user_client( + notif_type="social_form_danger", + notif_message=e, + account_name=self.name, + media=self.media_type, + ) + return False + + def _get_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 _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_fetch( + [ + ("media_type", "=", "x"), + ], + [ + "post_since_id", + "enable_since", + "x_account_id", + "name", + "username", + ], + ) + elif any(val.media_type == "x" for val in self): + account_ids = self + else: + return self._get_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 = [] + client_api = account.get_client_api( + bearer_token=account.x_access_token_oauth2 + ) + account.post_since_id.fetch(["x_post_account_id"]) + since_id = ( + account.post_since_id.x_post_account_id + if account.post_since_id + and account.post_since_id.x_post_account_id + and account.enable_since + else None + ) + response = client_api.get_users_tweets( + id=account.x_account_id, + 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, + ) + if not response.data: + message_error = "
".join( + [ + f"
{error.get('detail', '')}" + for error in response.errors + ] + ) + _logger.error(f"Get Tweets: {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 []) + } + 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 = PostAccount.search( + [("x_post_account_id", "=", val_x.id)], limit=1 + ) + 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 = val_x.public_metrics + like_count += public_metrics.get("like_count", 0) + impression_count += public_metrics.get( + "impression_count", 0 + ) + comment_count += public_metrics.get("reply_count", 0) + retweet_count += public_metrics.get("retweet_count", 0) + quote_count += public_metrics.get("quote_count", 0) + + data = { + "x_post_account_id": val_x.get("id"), + "post_account_url": f"https://x.com/{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.get("like_count", 0), + "impression_count": public_metrics.get( + "impression_count", 0 + ), + "comment_count": public_metrics.get("reply_count", 0), + "retweet_count": public_metrics.get("retweet_count", 0), + "quote_count": public_metrics.get("quote_count", 0), + "actor_urn": val_x.author_id, + "image_ids": image_ids, + "state": "posted", + "author": author.username, + } + if not post_account: + post_accounts.append((0, 0, data)) + else: + post_accounts.append((1, 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": impression_count, + "quote_count": quote_count, + "comment_count": comment_count, + "last_post_id": response.meta.get("newest_id", 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: + self._notify_user_client( + notif_type="social_kanban_danger", + notif_message=e, + media="X", + account_name=account.name, + ) + _logger.error(f"Error Get Tweets: {e}") + return self._get_statistics(statistics) + + def _action_valid_add_account(self): + result = super()._action_valid_add_account() + if self.media_type == "x": + if ( + self.env["social.account"].search_count( + [ + ("media_type", "=", "x"), + ("x_api_key", "=", self.x_api_key), + ("x_api_secret", "=", self.x_api_secret), + ] + ) + == 0 + ): + return False + return result + + def update_account(self): + res = super().update_account() + if self.media_type == "x": + ctx = dict(res.get("context", {})) + ctx.update( + { + "default_x_api_key": self.x_api_key, + "default_x_api_secret": self.x_api_secret, + } + ) + res["context"] = ctx + return res diff --git a/social_media_x/models/social_media.py b/social_media_x/models/social_media.py new file mode 100644 index 0000000000..080a9613ba --- /dev/null +++ b/social_media_x/models/social_media.py @@ -0,0 +1,24 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class SocialMedia(models.Model): + _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..74d6faee1f --- /dev/null +++ b/social_media_x/models/social_post.py @@ -0,0 +1,64 @@ +# 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): + _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", "campaign_id") + def _check_account_ids(self): + """ + This validation is done to check that X accounts do + not have the same user associated, to avoid publication + errors regarding repeating the same publication + for an account (username). + """ + for post in self: + account_repeat = post.account_ids._get_group_account_username() + if account_repeat and account_repeat[0].get("username_count", 0) > 1: + raise ValidationError( + self.env._( + "There are X accounts with the " + "same username (%s), please check to avoid spam errors.", + account_repeat[0]["username"], + ) + ) + + @api.depends( + "post_account_ids.like_count", + "post_account_ids.comment_count", + "post_account_ids.click_count", + "post_account_ids.share_count", + "post_account_ids.engagement", + "post_account_ids.impression_count", + "post_account_ids.retweet_count", + "post_account_ids.quote_count", + ) + def _compute_post_statistics(self): + res = super()._compute_post_statistics() + for post in self: + post.count_post_interactions = ( + post.count_post_clicks + + post.count_post_likes + + post.count_post_comments + + post.count_post_shares + + sum(post.mapped("post_account_ids.retweet_count")) + + sum(post.mapped("post_account_ids.quote_count")) + ) + return res 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..eafa247cbe --- /dev/null +++ b/social_media_x/models/social_post_account.py @@ -0,0 +1,260 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import itertools +import logging + +import requests +from tweepy.errors import TooManyRequests + +from odoo import Command, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class SocialPostAccount(models.Model): + _inherit = "social.post.account" + + x_post_account_id = fields.Char() + x_post_url = fields.Char() + retweet_count = fields.Integer(default=0) + quote_count = fields.Integer(default=0) + + def _action_post(self): + res = super()._action_post() + post_accounts = self.filter_by_media_types(["x"]) + if post_accounts: + for post_account in post_accounts: + post_account_id = 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, + ) + if post_account_id: + post_account.write( + { + "x_post_account_id": post_account_id, + "post_account_url": f"https://x.com/{post_account.account_id.username}/status/{post_account_id}", + "state": "posted", + "published_date": fields.Datetime.now(), + } + ) + else: + post_account.write( + { + "state": "failed", + } + ) + return res + + def get_comments(self): + 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.x_access_token_oauth2 + ) + query = ( + f"conversation_id:{self.x_post_account_id} " + 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", + ) + if response.data: + comments = [] + users = { + str(u.id): u + for u in (response.includes.get("users", []) or []) + } + for comment in response.data or []: + author = users.get(str(comment.author_id)) + 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": [ + val.get("media_key", {}) + for val in comment.get("includes", {}).get( + "media", {} + ) + ], + } + ) + + except TooManyRequests as exManyRequest: + self.account_id._get_message_many_requests( + exManyRequest, endpoint="get_comments" + ) + except Exception as e: + return_message = self.env._( + "Error Get Comments for Tweet: %(error)s)", error=e + ) + _logger.error(return_message) + return { + "success": False, + "message": return_message, + } + return { + "success": True, + "data": list(itertools.chain(data.get("data", []), comments)), + } + + def create_x_comment(self, post_data): + 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.x_post_account_id, + media_ids=media_ids, + ) + else: + client_api.create_tweet( + text=post_data.get("body", ""), + in_reply_to_tweet_id=self.x_post_account_id, + ) + except TooManyRequests as exManyRequest: + self.account_id._get_message_many_requests( + exManyRequest, endpoint="create_comment" + ) + except Exception as exp: + return_message = self.env._( + "Error Comment Tweet: %(error)s)", error=exp + ) + _logger.error(return_message) + 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 get_post_x(self): + if "x" == self.account_id.media_type and self.x_post_account_id: + message_error = "" + try: + result = self.account_id._valid_time_request(endpoint="get_post") + if result: + client_api = self.account_id.get_client_api( + bearer_token=self.account_id.x_access_token_oauth2 + ) + response = client_api.get_tweet( + self.x_post_account_id, tweet_fields=["id"] + ) + if response.errors: + message_error = ", ".join(response.errors) + except TooManyRequests as exManyRequest: + self.account_id._get_message_many_requests( + exManyRequest, endpoint="get_post" + ) + except Exception as e: + message_error = self.env._("Error Get Comment Post: %(error)s", error=e) + _logger.error(message_error) + if message_error: + raise ValidationError(message_error) + return True + return False + + 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: + client_api = self.account_id.get_client_api( + bearer_token=self.account_id.x_access_token_oauth2 + ) + response = client_api.delete_tweet(self.x_post_account_id) + 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: + message_error = self.env._("Error Delete Post X: %(error)s", error=e) + _logger.error(message_error) + if message_error: + raise ValidationError(message_error) + return super()._delete_post_account() + + def _get_assets_save_x(self, media_keys, media_map): + attachments = [] + medias_exist = ( + self.env["ir.attachment"] + .search_fetch( + [ + ("name", "in", media_keys), + ], + ["name"], + ) + .mapped("name") + ) + for media in media_keys: + if media not in medias_exist and media_map.get(media, False): + media_content = requests.get(media_map.get(media, False)[1], timeout=30) + if media_content.status_code == 200: + mimetype = ( + "image/jpeg" + if media_map.get(media, False)[2] == "photo" + else "video/mp4" + ) + attachments.append( + Command.create( + { + "name": media, + "type": "binary", + "mimetype": mimetype, + "res_model": self._name, + "res_id": self.id, + "datas": base64.b64encode(media_content.content), + } + ) + ) + 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..9238d5ae33 --- /dev/null +++ b/social_media_x/readme/CONFIGURE.md @@ -0,0 +1,71 @@ +To configure this module, you need to: +--------------- +Please note that you must have a developer account. +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](/social_media_x/static/img/readme/PROJECT_DEFAULT.png) + +- Then scroll to the bottom of the page and press the Edit button. + + ![CONFIGURATION_ACCOUNT](/social_media_x/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](/social_media_x/static/img/readme/APP_PERMISIONS.png) + +- Go to the App Type section and select Web App, Automated App or Bot. + + ![TYPE_APP](/social_media_x/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) + linkedin_url: http://192.168.1.7:8017/social_x/callback + + ![PROJECT_DEFAULT](/social_media_x/static/img/readme/PROJECT_DEFAULT.png) + +- Then go to the Website URL section and add your X profile address. + + Example: https://x.com/AccountTest + + ![PROJECT_DEFAULT](/social_media_x/static/img/readme/PROJECT_DEFAULT.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](/social_media_x/static/img/readme/KEYS_AND_TOKENS.png) + + ![GENERATE_NEW_KEY_SECRET](/social_media_x/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* > Settings > Social Media +- Click on the *Associate Account* button for the desired social network. + + ![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 diff --git a/social_media_x/readme/CONTRIBUTORS.md b/social_media_x/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..5d53953e46 --- /dev/null +++ b/social_media_x/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- [Binhex] (https://www.binhex.cloud): + - Edilio Escalona Almira + +- [Trobz] (https://trobz.com/): + - Khanh (Dinh Van) diff --git a/social_media_x/readme/DESCRIPTION.md b/social_media_x/readme/DESCRIPTION.md new file mode 100644 index 0000000000..5a2b126f10 --- /dev/null +++ b/social_media_x/readme/DESCRIPTION.md @@ -0,0 +1,24 @@ +This module provides the necessary functionality for +basic interaction with the X social network. + +Main features: +- User account integration. +- Post creation. +- Post reactions (likes, comments). +- Comment reactions (likes) +- Reports and graphs with agnostic metrics. + + +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. + +1. The eye icon: Total number of views, which may include multiple views by the same user. +2. The hand icon: means the impressions (likes, comments, shares, retweets, quote_count) + of current posts. +3. The star icon: means the value of the engagement of the publications, it is a calculation + similar to interactions / (impressions * 100). + + ![STATISTICS_ACCOUNT](/social_media_linkedin/static/img/readme/STATISTICS_ACCOUNT.png) diff --git a/social_media_x/readme/ROADMAP.md b/social_media_x/readme/ROADMAP.md new file mode 100644 index 0000000000..881aeb8866 --- /dev/null +++ b/social_media_x/readme/ROADMAP.md @@ -0,0 +1,9 @@ +- The free X developer account has significant limitations on post retrievals, writing, comments, likes, etc. + + * For more information, visit this link: https://developer.x.com/en/portal/products + * The action of giving like or followers is not enabled in the free version: 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 + * Frequently asked: https://developer.x.com/en/support/x-api/v2 + +- 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. diff --git a/social_media_x/readme/USAGE.md b/social_media_x/readme/USAGE.md new file mode 100644 index 0000000000..63c0bb8f1f --- /dev/null +++ b/social_media_x/readme/USAGE.md @@ -0,0 +1,62 @@ +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 + ![CREATE_POST](/social_media_x/static/img/readme/CREATE_POST.png) +- Save +- Click on the *Post* button + +Update token, client ID, client Secret and organization data +--------------- + +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Click on the *Update account* button + + ![BUTTON_UPDATE_ACCOUNT](/social_media_linkedin/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 organization's data. +- If the *Update keys* checkbox is selected, the current Client ID and Client Secret + values will be displayed by default. Modify any of these values and authentication + will be performed again through LinkedIn to update these values and the token. + + ![UPDATE_KEYS](/social_media_linkedin/static/img/readme/UPDATE_KEYS.png) + +- Selecting the *Update token* checkbox will update the current token. + + ![UPDATE_TOKEN](/social_media_linkedin/static/img/readme/UPDATE_TOKEN.png) + + +Archive Account X +---------------------------- +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Click on the *Delete account* button + + ![ARCHIVE_ACCOUNT](/social_media_linkedin/static/img/readme/ARCHIVE_ACCOUNT.png) + +- Please note that all data associated with this account will be archived. + +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](/social_media_linkedin/static/img/readme/ENABLE_SINCE.png) diff --git a/social_media_x/social_x_utils.py b/social_media_x/social_x_utils.py new file mode 100644 index 0000000000..33a2a6e894 --- /dev/null +++ b/social_media_x/social_x_utils.py @@ -0,0 +1,38 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import hashlib +import os + +from requests_oauthlib import OAuth1 + +_URL_REST_X = "https://api.x.com" +_URL_V2_X = "https://api.x.com/2" + +_SCOPES_X = [ + "tweet.read", + "tweet.write", + "tweet.read", + "offline.access", +] + + +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) + + +def _get_code_challenge(): + code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").rstrip("=") + code_challenge = hashlib.sha256(code_verifier.encode("utf-8")).digest() + code_challenge = ( + base64.urlsafe_b64encode(code_challenge).decode("utf-8").rstrip("=") + ) + return code_challenge 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..d31fab6988 --- /dev/null +++ b/social_media_x/static/description/index.html @@ -0,0 +1,634 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

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

+

Main features:

+
    +
  • User account integration.
  • +
  • Post creation.
  • +
  • Post reactions (likes, comments).
  • +
  • Comment reactions (likes)
  • +
  • Reports and graphs with agnostic metrics.
  • +
+
+

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.

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

    +
  2. +
  3. The hand icon: means the impressions (likes, comments, shares, +retweets, quote_count) of current posts.

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

Table of contents

+ + +
+
+

To configure this module, you need to:

+

Please note that you must have a developer account. 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:

    + +

    PROJECT_DEFAULT

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

    +

    Example: https://x.com/AccountTest

    +

    PROJECT_DEFAULT

    +
  • +
  • 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 > Settings > Social Media

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

    +

    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

    +
  • +
+
+

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 CREATE_POST
  • +
  • Save
  • +
  • Click on the Post button
  • +
+
+
+

Update token, client ID, client Secret and organization 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 +organization’s data.

    +
  • +
  • If the Update keys checkbox is selected, the current Client ID and +Client Secret values will be displayed by default. Modify any of these +values and authentication will be performed again through LinkedIn 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 Delete account button

    +

    ARCHIVE_ACCOUNT

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

    +
  • +
+
+
+

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

    +
  • +
+
+

Known issues / Roadmap

+ +
+
+

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.

+

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/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/services/social_x_service.esm.js b/social_media_x/static/src/js/services/social_x_service.esm.js new file mode 100644 index 0000000000..74b617c95f --- /dev/null +++ b/social_media_x/static/src/js/services/social_x_service.esm.js @@ -0,0 +1,29 @@ +import {registry} from "@web/core/registry"; + +export const socialXService = { + dependencies: ["orm"], + + /** + * Returns an object with the following methods: + * - `createXComment`: Creates a comment on a social network post. + * + * @param {Object} env - web environment + * @param {Object} services - services to use + * @param {Object} services.orm - ORM service + * @returns {Object} - an object with the methods `createXComment` + */ + async start(env, {orm}) { + return { + async validPostXExist(post_account_id) { + if (!post_account_id) { + return false; + } + return await orm.call("social.post.account", "get_post_x", [ + post_account_id, + ]); + }, + }; + }, +}; + +registry.category("services").add("social_x_service", socialXService); 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..651b7e7467 --- /dev/null +++ b/social_media_x/static/src/js/views/kanban/network_post_account_kanban_record.esm.js @@ -0,0 +1,21 @@ +import {SocialKanbanRecord} from "@social_media_base/js/views/kanban/social_kanban_record.esm"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +patch(SocialKanbanRecord.prototype, { + /** + * @override + */ + setup() { + super.setup(); + this.socialXService = useService("social_x_service"); + this.record.notAvailableLike += ["x"]; + }, + async validPostExist() { + const res = super.validPostExist(); + if (this.record.media_type.raw_value === "x") { + return await this.socialXService.validPostXExist(this.record.id.raw_value); + } + return res; + }, +}); diff --git a/social_media_x/tests/__init__.py b/social_media_x/tests/__init__.py new file mode 100644 index 0000000000..f197fd0ee6 --- /dev/null +++ b/social_media_x/tests/__init__.py @@ -0,0 +1,7 @@ +# 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_account_x +from . import test_media_x 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..0f5ceb661d --- /dev/null +++ b/social_media_x/tests/test_account_x.py @@ -0,0 +1,479 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch + +from odoo.exceptions import ValidationError + +from odoo.addons.social_media_base.tests.test_social_common import ( + PATCH_SOCIAL_BASE_MIXIN, +) + +from .test_common_x import ( + PATCH_ACCOUNT_X, + PATCH_REQUEST_POST, + PATCH_WIZARD_ACCOUNT, + TestSocialCommonX, +) + + +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): + kwargs = { + "oauth_token": "wiz-token-123", + "oauth_verifier": "verif-xyz", + } + 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_falls_back_to_model_credentials_when_no_wizard( + self, mock_get_oauth, mock_post + ): + kwargs = { + "oauth_token": "no-match-token", + "oauth_verifier": "verif-abc", + } + mock_post.return_value.text = "oauth_token=A&oauth_token_secret=B" + fake_auth = object() + mock_get_oauth.return_value = fake_auth + token, secret = self.SocialAccountCredentialX._get_access_token(kwargs) + self.assertEqual(token, "A") + self.assertEqual(secret, "B") + 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-abc"}, + timeout=10, + ) + + @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, # idem + ) + + @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): + # Guardamos lo que recibe para asserts + 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 test_create_tweet_with_medias(self): + with ( + patch.object(type(self.SocialAccount), "get_client_api") as mock_get_api, + patch.object( + type(self.SocialAccount), "_prepare_medias_for_tweet" + ) as mock_prep, + ): + fake_client = Mock() + fake_client.create_tweet.return_value = type( + "Response", (), {"data": {"id": "123"}} + )() + mock_get_api.return_value = fake_client + mock_prep.return_value = [101, 102] + image_ids = ["dummy"] + tweet_id = self.SocialAccount.create_tweet("Message test", image_ids, []) + self.assertEqual(tweet_id, "123") + mock_get_api.assert_called_once_with() + mock_prep.assert_called_once_with(image_ids=image_ids, video_ids=[]) + fake_client.create_tweet.assert_called_once_with( + text="Message test", + media_ids=[101, 102], + ) + + @patch(PATCH_SOCIAL_BASE_MIXIN.format("_notify_user_client")) + def test_create_tweet_without_medias(self, mock_notify): + with ( + patch.object(type(self.SocialAccount), "get_client_api") as mock_get_api, + patch.object( + type(self.SocialAccount), "_prepare_medias_for_tweet", return_value=[] + ) as mock_prep, + ): + fake_client = Mock() + fake_client.create_tweet.return_value = type( + "Response", (), {"data": {"id": "456"}} + )() + mock_get_api.return_value = fake_client + tweet_id = self.SocialAccount.create_tweet( + "Without images", image_ids=[], video_ids=[] + ) + self.assertEqual(tweet_id, "456") + mock_get_api.assert_called_once_with() + mock_prep.assert_called_once_with(image_ids=[], video_ids=[]) + fake_client.create_tweet.assert_called_once_with( + text="Without images", + media_ids=None, + ) + mock_notify.assert_not_called() + + def _patch_super(self, record, return_value): + SocialAccount = record.__class__ + ParentSocialAccount = SocialAccount.__mro__[1] + return patch.object( + ParentSocialAccount, "_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: + res = self.WizardAccountX._action_valid_add_account() + mock_super.assert_called_once_with() + self.assertEqual(res, "SUPER_OK") + + 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): + SocialClass = record.__class__ + ParentSocialClass = SocialClass.__mro__[1] + return patch.object( + ParentSocialClass, "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.SocialAccount, super_action) as mock_super: + res = self.SocialAccount.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"], "DEF_TEST_KEY") + self.assertEqual(res["context"]["default_x_api_secret"], "DEF_TEST_SECRET") + + def _patch_super_action_add_account(self, record, return_value): + SocialClass = record.__class__ + ParentSocialClass = SocialClass.__mro__[1] + return patch.object( + ParentSocialClass, "_action_add_account", return_value=return_value + ) + + def test_action_add_account(self): + with ( + self._patch_super_action_add_account( + self.WizardAccountX, + return_value={ + "type": "ir.actions.act_url", + "url": "https://auth.example", + }, + ) as mock_super, + patch.object( + type(self.WizardAccountX), + "_get_url_authorize", + return_value={ + "type": "ir.actions.act_url", + "url": "https://auth.example", + }, + ), + ): + res = self.WizardAccountX._action_add_account() + mock_super.assert_called_once_with() + self.assertEqual( + res, {"type": "ir.actions.act_url", "url": "https://auth.example"} + ) + + @patch(PATCH_REQUEST_POST) + @patch(PATCH_WIZARD_ACCOUNT.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(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.format("_logger")) + @patch(PATCH_REQUEST_POST) + @patch(PATCH_WIZARD_ACCOUNT.format("_get_oauth")) + def test_valueerror_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")) + + def _fake_client(self, user_id="ID123", name="User Name", username="user"): + me = SimpleNamespace( + data=SimpleNamespace(id=user_id, name=name, username=username) + ) + client = Mock() + client.get_me.return_value = me + return client + + def test_does_nothing_if_x_account_id_already_exists(self): + client = self._fake_client( + user_id="FAKE123456789", name="Same", username="same" + ) + + with ( + patch.object( + type(self.SocialAccount), "get_client_api", return_value=client + ), + patch.object( + type(self.SocialAccount), + "_get_access_token_oauth2", + return_value="BT123", + ) as mock_o2, + patch.object( + self.SocialAccount.env, "ref", return_value=SimpleNamespace(id=222) + ) as mock_ref, + patch.object(type(self.SocialAccount), "create") as mock_create, + patch.object(type(self.SocialAccount), "write") as mock_write, + patch.object( + type(self.SocialAccount), "_notify_user_client" + ) as mock_notify, + ): + self.SocialAccount.create_account_x( + x_access_token_oauth1="AT", + x_access_secret_oauth1="AS", + kwargs={"oauth_token": "tok-1"}, + ) + mock_o2.assert_not_called() + mock_ref.assert_not_called() + mock_create.assert_not_called() + mock_write.assert_not_called() + mock_notify.assert_not_called() + + def test_update_account(self): + fake_url = { + "type": "ir.actions.act_url", + "url": "https://example.com", + "target": "self", + } + self.WizardAccountX.write({"update_keys": True}) + with patch.object( + type(self.WizardAccount), "_get_url_authorize", return_value=fake_url + ): + result = self.WizardAccountX._update_account() + self.assertEqual(result["type"], "ir.actions.act_url") + self.assertEqual(result["url"], "https://example.com") + self.assertEqual(result["target"], "self") + + def test_action_valid_add_account(self): + wizard_id = self.WizardAccount.create( + { + "x_api_key": "TEST_KEY1", + "x_api_secret": "TEST_SECRET1", + "media_id": self.media_x_id.id, + } + ) + result = wizard_id._action_valid_add_account() + self.assertTrue(result) + + wizard_id.write({"x_api_key": "TEST_KEY", "x_api_secret": "TEST_SECRET"}) + with self.assertRaises(ValidationError): + wizard_id._action_valid_add_account() diff --git a/social_media_x/tests/test_common_x.py b/social_media_x/tests/test_common_x.py new file mode 100644 index 0000000000..3f8b73a246 --- /dev/null +++ b/social_media_x/tests/test_common_x.py @@ -0,0 +1,98 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.fields import Command + +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.social_media_base.tests.test_social_common import ( + TestSocialMediaBaseCommon, +) + +PATCH_ACCOUNT_X = "odoo.addons.social_media_x.models.social_account.{}" +PATCH_SOCIAL_X_WIZARDS = "odoo.addons.social_media_x.wizards" +PATCH_POST_ACCOUNT_X = ( + "odoo.addons.social_media_x.models.social_post_account.SocialPostAccount.{}" +) +PACTH_MEDIA_MODELS_X = "odoo.addons.social_media_x.models.{}" +PATCH_MEDIA_X = PACTH_MEDIA_MODELS_X.format("social_media.SocialMedia.{}") + +PATCH_X_UTILS = "odoo.addons.social_media_x.social_x_utils.{}" +PATCH_REQUEST_POST = PATCH_ACCOUNT_X.format("requests.post") + +PATCH_WIZARD_ACCOUNT = "{}.wizard_social_account.{}".format( + PATCH_SOCIAL_X_WIZARDS, "{}" +) + + +class TestSocialCommonX(TestSocialMediaBaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.media_x_id = cls.SocialMedia.create( + { + "name": "X", + "media_type": "x", + } + ) + + account_values = { + "name": "X Account", + "media_id": cls.media_x_id.id, + "access_token": "fake-token", + "x_account_id": "FAKE123456789", + "username": "fake-username", + } + + cls.SocialAccountX = cls.SocialAccount.create(account_values) + + account_values.update( + { + "x_api_key": "TEST_KEY", + "x_api_secret": "TEST_SECRET", + } + ) + + cls.SocialAccountCredentialX = cls.SocialAccount.create(account_values) + + cls.SocialPostX = cls.SocialPost.create( + { + "message": "Test Message", + "account_ids": [Command.set(cls.SocialAccountX.ids)], + } + ) + + post_account = { + "message": "Test Message", + "account_id": cls.SocialAccountX.id, + "media_id": cls.media_x_id.id, + "post_id": cls.SocialPostX.id, + "state": "posted", + "x_post_account_id": "159753456", + } + + cls.SocialPostAccountX = cls.SocialPostAccount.create(post_account) + cls.WizardAccountX = cls.WizardAccount.create( + { + "x_api_key": "TEST_KEY", + "x_api_secret": "TEST_SECRET", + "media_id": cls.media_x_id.id, + } + ) + + cls.SocialAccountEmptyX = cls.SocialAccount.create( + { + "name": "Twitter", + "x_api_key": False, + "x_api_secret": False, + "x_access_token_oauth1": False, + "x_access_secret_oauth1": False, + "x_access_token_oauth2": False, + } + ) + cls.admin_media_x_admin = mail_new_test_user( + cls.env, + groups="base.group_user,base.group_system", + login="admin_media_x_admin", + name="Admin Media X Admin", + signature="--\nMEDIAX", + ) diff --git a/social_media_x/tests/test_media_x.py b/social_media_x/tests/test_media_x.py new file mode 100644 index 0000000000..dfb9dd70ae --- /dev/null +++ b/social_media_x/tests/test_media_x.py @@ -0,0 +1,58 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.addons.social_media_x.tests.test_common_x import ( + TestSocialCommonX, +) + +from ..social_x_utils import _get_code_challenge, _get_oauth + + +class TestSocialMediaX(TestSocialCommonX): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_get_oauth(self): + request_access_token = { + "oauth_token": "TEST-ACCESS-TOKEN", + "oauth_token_secret": "TEST-ACCESS-TOKEN-SECRET", + } + result = _get_oauth( + api_key="TEST-API-KEY", + api_secret="TEST-API-SECRET", + request_access_token=request_access_token, + ) + self.assertEqual(result.client.client_key, "TEST-API-KEY") + self.assertEqual(result.client.client_secret, "TEST-API-SECRET") + self.assertEqual(result.client.resource_owner_key, "TEST-ACCESS-TOKEN") + self.assertEqual( + result.client.resource_owner_secret, "TEST-ACCESS-TOKEN-SECRET" + ) + result = _get_oauth( + api_key="TEST-API-KEY", + api_secret="TEST-API-SECRET", + request_access_token=None, + ) + self.assertEqual(result.client.client_key, "TEST-API-KEY") + self.assertEqual(result.client.client_secret, "TEST-API-SECRET") + self.assertEqual(result.client.resource_owner_key, None) + self.assertEqual(result.client.resource_owner_secret, None) + + def test_get_code_challenge(self): + code_challenge = _get_code_challenge() + self.assertIsInstance(code_challenge, str) + self.assertTrue(len(code_challenge) > 0) + self.assertRegex(code_challenge, r"^[A-Za-z0-9_-]+$") + self.assertGreaterEqual(len(code_challenge), 43) + self.assertLessEqual(len(code_challenge), 128) + + def test_open_action_account_media_x(self): + action = self.media_x_id.open_action_account() + self.assertIsInstance(action, dict) + self.assertEqual(action.get("type"), "ir.actions.act_window") + self.assertEqual(action.get("res_model"), self.WizardAccount._name) + self.assertEqual(action.get("target"), "new") + ctx = action.get("context") or {} + self.assertEqual(ctx.get("default_media_id"), self.media_x_id.id) diff --git a/social_media_x/tests/test_post_account_x.py b/social_media_x/tests/test_post_account_x.py new file mode 100644 index 0000000000..873c281d3a --- /dev/null +++ b/social_media_x/tests/test_post_account_x.py @@ -0,0 +1,77 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import MagicMock, patch + +from odoo.addons.social_media_base.tests.test_social_common import ( + PATCH_POST_ACCOUNT, +) +from odoo.addons.social_media_x.tests.test_common_x import ( + TestSocialCommonX, +) + +from .test_common_x import PATCH_ACCOUNT_X + + +class TestSocialPostAccountX(TestSocialCommonX): + @classmethod + def setUpClass(cls): + super().setUpClass() + + @patch(PATCH_POST_ACCOUNT.format("filter_by_media_types")) + @patch(PATCH_ACCOUNT_X.format("SocialAccount.create_tweet")) + def test_action_post(self, mock_create_tweet, mock_filter_by_media_types): + mock_filter_by_media_types.return_value = [MagicMock()] + mock_create_post_data = MagicMock() + mock_create_post_data.return_value = "159753456" + mock_create_tweet.return_value = mock_create_post_data + self.social_post_account_id._action_post() + self.assertGreaterEqual( + self.SocialPostAccount.search_count( + [ + ("x_post_account_id", "=", "159753456"), + ( + "post_account_url", + "=", + "https://x.com/fake-username/status/159753456", + ), + ] + ), + 0, + ) + self.assertEqual( + mock_filter_by_media_types.call_count, + 1, + ) + + def test_create_x_comment(self): + mock_client = MagicMock() + with ( + patch.object( + type(self.SocialAccountX), "get_client_api", return_value=mock_client + ) as mock_get_client_api, + patch.object( + type(self.SocialAccountX), + "_prepare_medias_for_tweet", + return_value=mock_client, + ) as mock_medias_for_tweet, + ): + post_data = { + "body": "Test Comment", + "attachment_ids": [1], + } + self.SocialPostAccountX.create_x_comment(post_data) + mock_medias_for_tweet.assert_called_once() + self.assertEqual(mock_get_client_api.call_count, 1) + + def test_create_comment(self): + mock_client = MagicMock() + with patch.object( + type(self.SocialPostAccountX), "create_x_comment", return_value=mock_client + ) as mock_create_comment: + post_data = { + "body": "Test Comment", + "attachment_ids": [1], + } + self.SocialPostAccountX.create_comment(post_data) + mock_create_comment.assert_called_once() diff --git a/social_media_x/views/social_account_views.xml b/social_media_x/views/social_account_views.xml new file mode 100644 index 0000000000..10b5976171 --- /dev/null +++ b/social_media_x/views/social_account_views.xml @@ -0,0 +1,51 @@ + + + + + social.account.view.form.inherit.x + social.account + + + + + + + + + + + + + + + diff --git a/social_media_x/wizards/__init__.py b/social_media_x/wizards/__init__.py new file mode 100644 index 0000000000..0b26b95492 --- /dev/null +++ b/social_media_x/wizards/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from . import wizard_social_account diff --git a/social_media_x/wizards/wizard_social_account.py b/social_media_x/wizards/wizard_social_account.py new file mode 100644 index 0000000000..9fde28851a --- /dev/null +++ b/social_media_x/wizards/wizard_social_account.py @@ -0,0 +1,90 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +import requests +from werkzeug.urls import url_encode + +from odoo import fields, models +from odoo.exceptions import ValidationError + +from ..social_x_utils import _get_oauth + +_logger = logging.getLogger(__name__) + + +class WizardSocialAccount(models.TransientModel): + _inherit = "wizard.social.account" + + x_api_key = fields.Char(string="API Key") + x_api_secret = fields.Char(string="API Secret") + oauth_token = fields.Char() + + def _get_url_authorize(self): + try: + url = "https://api.twitter.com/oauth/request_token" + auth = _get_oauth(self.x_api_key, self.x_api_secret) + response = requests.post(url, auth=auth, timeout=10) + tokens = dict(x.split("=") for x in response.text.split("&")) + params = {"oauth_token": tokens["oauth_token"]} + self.oauth_token = tokens["oauth_token"] + url_aut = f"https://api.twitter.com/oauth/authorize?{url_encode(params)}" + return { + "type": "ir.actions.act_url", + "url": url_aut, + "target": "self", + } + except ValueError as e: + _logger.error(f"Error get url authorize {e}") + return { + "type": "ir.actions.client", + "tag": "display_notification", + "target": "new", + "params": { + "message": self.env._( + """ + Account access could not be authorized. + Please check your settings or try again later. + """ + ), + "type": "danger", + "sticky": False, + "next": {"type": "ir.actions.act_window_close"}, + }, + } + + def _action_add_account(self): + result = super()._action_add_account() + if self.media_type == "x": + return self._get_url_authorize() + else: + return result + + def _action_valid_add_account(self): + result = super()._action_valid_add_account() + if self.media_type == "x": + if ( + self.env["social.account"].search_count( + [ + ("media_type", "=", "x"), + ("x_api_key", "=", self.x_api_key), + ("x_api_secret", "=", self.x_api_secret), + ] + ) + > 0 + ): + raise ValidationError( + self.env._("An account with that information already exists.") + ) + return result + + def _update_account(self): + if self.media_type == "x": + if self.update_keys or self.update_token: + return self._get_url_authorize() + else: + self.account_id._update_account_data() + return super()._update_account() + else: + return super()._update_account() diff --git a/social_media_x/wizards/wizard_social_account.xml b/social_media_x/wizards/wizard_social_account.xml new file mode 100644 index 0000000000..1ab2763b3c --- /dev/null +++ b/social_media_x/wizards/wizard_social_account.xml @@ -0,0 +1,29 @@ + + + + + Associate Social Account + wizard.social.account + + + + + + + + + + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..bbe57d65b0 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-social_media_base @ git+https://github.com/OCA/social.git@refs/pull/1788/head#subdirectory=social_media_base