# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import dateutil.parser import base64 import contextlib import hmac import hashlib import re import requests import uuid import time import xml.etree.ElementTree as XmlElementTree from html import unescape from werkzeug.urls import url_quote from odoo import models, fields, api, _ from odoo.addons.iap.tools import iap_tools from odoo.exceptions import UserError, AccessError from odoo.tools.urls import urljoin as url_join class SocialMedia(models.Model): _inherit = 'social.media' _TWITTER_ENDPOINT = 'https://api.twitter.com' media_type = fields.Selection(selection_add=[('twitter', 'X')]) def _action_add_account(self): """ Builds the URL to Twitter in order to allow account access, then redirects the client. Redirect is done in 'self' since Twitter will then return back to the app with the 'oauth_callback' param. Redirect URI from Twitter will land on this module controller's 'twitter_account_callback' method. We raise an error message if request_token endpoint is not successfull. (Most likely reason for that to happen: the callback URI is not correctly defined in the Twitter APP settings) """ self.ensure_one() if self.media_type != 'twitter': return super()._action_add_account() twitter_consumer_key = self.env['ir.config_parameter'].sudo().get_param('social.twitter_consumer_key') twitter_consumer_secret_key = self.env['ir.config_parameter'].sudo().get_param('social.twitter_consumer_secret_key') if twitter_consumer_key and twitter_consumer_secret_key: return self._add_twitter_accounts_from_configuration() return self._add_twitter_accounts_from_iap() def _add_twitter_accounts_from_configuration(self): twitter_oauth_url = url_join(self._TWITTER_ENDPOINT, "oauth/request_token") headers = self._get_twitter_oauth_header( twitter_oauth_url, headers={'oauth_callback': url_join(self.get_base_url(), "social_twitter/callback")} ) response = requests.post(twitter_oauth_url, headers=headers, timeout=5) if response.status_code != 200: raise UserError(self._extract_error_message(response)) response_values = { response_value.split('=')[0]: response_value.split('=')[1] for response_value in response.text.split('&') } twitter_authorize_url = url_join(self._TWITTER_ENDPOINT, 'oauth/authorize') return { 'name': 'Add Account', 'type': 'ir.actions.act_url', 'url': f'{twitter_authorize_url}?oauth_token={response_values["oauth_token"]}', 'target': 'self' } def _add_twitter_accounts_from_iap(self): social_iap_endpoint = self.env['ir.config_parameter'].sudo().get_param( 'social.social_iap_endpoint', self.env['social.media']._DEFAULT_SOCIAL_IAP_ENDPOINT ) iap_add_accounts_url = requests.get(url_join(social_iap_endpoint, 'api/social/twitter/1/add_accounts'), params={ 'returning_url': url_join(self.get_base_url(), 'social_twitter/callback'), 'db_uuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid') }, timeout=5 ).text if iap_add_accounts_url == 'unauthorized': raise UserError(_( "Oops! You currently don't have an active subscription. No worries, though! " "You can easily get one here: %s.\n" "Grab a subscription and unlock a world of amazing features!", 'https://www.odoo.com/buy')) if iap_add_accounts_url == 'wrong_configuration': raise UserError(_("The url that this service requested returned an error. Please contact the author of the app.")) return { 'type': 'ir.actions.act_url', 'url': iap_add_accounts_url, 'target': 'self' } def _extract_error_message(self, response): """ This method tries to extract the error code of the response. Code '415' simply means that the user has not correctly configured their Twitter account so we help them by displaying a nice error message with what they need to do. If we can't parse the document or if the code is different, we return the raw response text value. """ with contextlib.suppress(XmlElementTree.ParseError): document_root = XmlElementTree.fromstring(response.text) error_node = document_root.find('error') if error_node is not None and error_node.get('code') == '415': return _('You need to add the following callback URL to your X application settings: %s', url_join(self.get_base_url(), "social_twitter/callback")) return response.text def _get_twitter_oauth_header(self, url, headers={}, params={}, method='POST'): """ Twitter needs parameters to contain a 'oauth_signature'. This signature requires that all the headers and params are encoded inside it. """ twitter_consumer_key = self.env['ir.config_parameter'].sudo().get_param('social.twitter_consumer_key') header_params = { 'oauth_nonce': uuid.uuid4(), 'oauth_consumer_key': twitter_consumer_key, 'oauth_signature_method': 'HMAC-SHA1', 'oauth_timestamp': str(int(time.time())), 'oauth_version': '1.0', } oauth_token_secret = headers.pop('oauth_token_secret', '') header_params.update(headers) signature_params = {} signature_params.update(header_params) signature_params.update(params) header_params['oauth_signature'] = self._get_twitter_oauth_signature( method, url, signature_params, oauth_token_secret=oauth_token_secret ) header_oauth = 'OAuth ' + ', '.join([('%s="%s"' % (key, url_quote(header_params[key], unsafe='+:/'))) for key in sorted(header_params.keys())]) return {'Authorization': header_oauth} def _get_twitter_oauth_signature(self, method, url, params, oauth_token_secret=''): """ Special signature handling as requested by Twitter. For more info: https://developer.twitter.com/en/docs/basics/authentication/guides/creating-a-signature.html """ self.ensure_one() twitter_consumer_secret_key = self.env['ir.config_parameter'].sudo().get_param('social.twitter_consumer_secret_key') if twitter_consumer_secret_key: return self._get_twitter_oauth_signature_from_configuration(method, url, params, twitter_consumer_secret_key, oauth_token_secret) return self._get_twitter_oauth_signature_from_iap(method, url, params, oauth_token_secret) def _get_twitter_oauth_signature_from_configuration(self, method, url, params, twitter_consumer_secret_key, oauth_token_secret=''): signing_key = '&'.join([twitter_consumer_secret_key, oauth_token_secret]) base_string = '&'.join([ method, url_quote(url, unsafe='+:/'), url_quote('&'.join([ ('%s=%s' % (url_quote(key, unsafe='+:/'), url_quote(params[key], unsafe='+:/,'))) for key in sorted(params.keys()) ]), unsafe='+:/') ]) signed_sha1 = hmac.new(signing_key.encode(), base_string.encode(), hashlib.sha1).digest() return base64.b64encode(signed_sha1).decode() def _get_twitter_oauth_signature_from_iap(self, method, url, params, oauth_token_secret=''): params['oauth_nonce'] = str(params['oauth_nonce']) json_params = { 'method': method, 'url': url, 'params': params, 'oauth_token_secret': oauth_token_secret, 'db_uuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid') } social_iap_endpoint = self.env['ir.config_parameter'].sudo().get_param( 'social.social_iap_endpoint', self.env['social.media']._DEFAULT_SOCIAL_IAP_ENDPOINT ) try: return iap_tools.iap_jsonrpc(url_join(social_iap_endpoint, 'api/social/twitter/1/get_signature'), params=json_params) except AccessError: return None @api.model def _format_tweet(self, tweet): """ Formats a tweet returned by the Twitter API to a dict that will be interpreted by our frontend. """ if 'created_at' in tweet: created_date = fields.Datetime.from_string( dateutil.parser.parse(tweet.get('created_at')).strftime('%Y-%m-%d %H:%M:%S')) else: created_date = fields.Datetime.now() in_reply_to_tweet_id = next((referenced['id'] for referenced in tweet.get('referenced_tweets', []) if referenced['type'] == 'replied_to'), None) formatted_tweet = { 'id': tweet.get('id'), 'message': unescape(tweet.get('text', '')), 'from': { 'id': tweet.get('author_id'), 'name': tweet.get('author', {}).get('name'), 'screen_name': tweet.get('author', {}).get('username'), 'profile_image_url': tweet.get('author', {}).get('profile_image_url'), }, 'created_time': tweet.get('created_at'), 'formatted_created_time': self.env['social.stream.post']._format_published_date( fields.Datetime.from_string(created_date)), 'user_likes': False, 'likes': { 'summary': { 'total_count': tweet.get('public_metrics', {}).get('like_count', 0), }, }, 'comments': {'data': []}, 'in_reply_to_tweet_id': in_reply_to_tweet_id, } attached_medias = tweet.get('medias') if attached_medias: if attached_medias[0].get('type') == 'photo': formatted_tweet['attachment'] = { 'type': 'photo', 'media': { 'image': { 'src': attached_medias[0].get('url'), } } } # check if we can reply to the tweet handle = tweet.get('social_account_handle') users = tweet.get('users', {}) conversation_thread_tweets = tweet.get('conversation_thread_tweets', {}) can_reply = False if handle and re.search( f'\\B@{re.escape(handle)}\\b', tweet.get('text', ''), ): # We are mentioned in the tweet can_reply = True elif handle and tweet.get('author', {}).get('username') == handle: # we are the author of the tweet can_reply = True elif handle and any( users.get(conversation_thread_tweets.get(referenced_tweet['id'], {}).get('author_id'), {}).get('username') == handle for referenced_tweet in tweet.get('referenced_tweets', ()) ): # the tweet is a quote to one of our tweet # `referenced_tweets` contains the tweets that the user quoted # (or replied to, but in that case the user is mentioned in the body) can_reply = True formatted_tweet['canReply'] = can_reply return formatted_tweet