chore: import enterprise addons 19.0 bulk snapshot

This commit is contained in:
1b-agent committed 2026-09-16 10:13:08 +00:00
commit ca4af030d3
39951 files changed
+15570005

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import res_config_settings
from . import social_media
from . import social_account
from . import social_post
from . import social_post_template
from . import social_live_post
from . import social_stream
from . import social_stream_post
from . import social_twitter_account
@@ -0,0 +1,47 @@
# coding: utf-8
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
twitter_use_own_account = fields.Boolean("Use your own X Account", config_parameter='social.twitter_use_own_account',
help="""Check this if you want to use your personal X Developer Account instead of the provided one.""")
twitter_consumer_key = fields.Char("X Consumer Key",
compute='_compute_twitter_consumer_key', inverse='_inverse_twitter_consumer_key')
twitter_consumer_secret_key = fields.Char("X Consumer Secret Key",
compute='_compute_twitter_consumer_secret_key', inverse='_inverse_twitter_consumer_secret_key')
@api.onchange('twitter_use_own_account')
def _onchange_twitter_use_own_account(self):
if not self.twitter_use_own_account:
self.twitter_consumer_key = False
self.twitter_consumer_secret_key = False
@api.depends('twitter_use_own_account')
def _compute_twitter_consumer_key(self):
for record in self:
if self.env.user.has_group('social.group_social_manager'):
record.twitter_consumer_key = self.env['ir.config_parameter'].sudo().get_param('social.twitter_consumer_key')
else:
record.twitter_consumer_key = None
def _inverse_twitter_consumer_key(self):
for record in self:
if self.env.user.has_group('social.group_social_manager'):
self.env['ir.config_parameter'].sudo().set_param('social.twitter_consumer_key', record.twitter_consumer_key)
@api.depends('twitter_use_own_account')
def _compute_twitter_consumer_secret_key(self):
for record in self:
if self.env.user.has_group('social.group_social_manager'):
record.twitter_consumer_secret_key = self.env['ir.config_parameter'].sudo().get_param('social.twitter_consumer_secret_key')
else:
record.twitter_consumer_secret_key = None
def _inverse_twitter_consumer_secret_key(self):
for record in self:
if self.env.user.has_group('social.group_social_manager'):
self.env['ir.config_parameter'].sudo().set_param('social.twitter_consumer_secret_key', record.twitter_consumer_secret_key)
+233
View File
@@ -0,0 +1,233 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import requests
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools.urls import urljoin as url_join
TWITTER_IMAGES_UPLOAD_ENDPOINT = "https://api.x.com/2/media/upload"
class SocialAccount(models.Model):
_inherit = 'social.account'
twitter_user_id = fields.Char('X User ID')
twitter_oauth_token = fields.Char('X OAuth Token')
twitter_oauth_token_secret = fields.Char('X OAuth Token Secret')
def _compute_statistics(self):
""" See methods '_get_last_tweets_stats' for more info about Twitter stats. """
twitter_accounts = self._filter_by_media_types(['twitter'])
super(SocialAccount, (self - twitter_accounts))._compute_statistics()
for account in twitter_accounts:
account_stats = account._get_account_stats()
last_tweets_stats = account._get_last_tweets_stats()
if account_stats and last_tweets_stats:
account.write({
'audience': account_stats.get('data', [{}])[0].get('public_metrics', {}).get('followers_count'),
'engagement': last_tweets_stats['engagement'],
'stories': last_tweets_stats['stories'],
})
def _compute_stats_link(self):
twitter_accounts = self._filter_by_media_types(['twitter'])
super(SocialAccount, (self - twitter_accounts))._compute_stats_link()
for account in twitter_accounts:
account.stats_link = f"https://analytics.twitter.com/user/{account.social_account_handle}"
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res.filtered(lambda account: account.media_type == 'twitter')._create_default_stream_twitter()
return res
def twitter_get_user_by_username(self, username):
"""Search a user based on his username (e.g: "fpodoo").
Can not search by name, can only get user by their usernames
See: https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference
"""
user_search_endpoint = url_join(
self.env['social.media']._TWITTER_ENDPOINT,
'/2/users/by/username/%s' % username)
params = {'user.fields': 'id,name,username,description,profile_image_url'}
headers = self._get_twitter_oauth_header(
user_search_endpoint,
params=params,
method='GET'
)
response = requests.get(
user_search_endpoint,
params=params,
headers=headers,
timeout=5
)
return response.json().get('data', False) if response.ok else False
def _create_default_stream_twitter(self):
""" This will create a stream of type 'Twitter Follow' for each added accounts.
It helps with onboarding to have your tweets show up on the 'Feed' view as soon as you have configured your accounts."""
if not self:
return
own_tweets_stream_type_id = self.env.ref('social_twitter.stream_type_twitter_follow').id
streams_to_create = []
for account in self:
# we have to create a matching social.twitter.account for each stream
twitter_followed_account = self.env['social.twitter.account'].create({
'name': account.name,
'twitter_id': account.twitter_user_id,
'image': account.image
})
streams_to_create.append({
'media_id': account.media_id.id,
'stream_type_id': own_tweets_stream_type_id,
'account_id': account.id,
'twitter_followed_account_id': twitter_followed_account.id
})
self.env['social.stream'].create(streams_to_create)
def _get_account_stats(self):
""" Query the account information to retrieve the Twitter audience (= followers count). """
self.ensure_one()
twitter_account_info_url = url_join(self.env['social.media']._TWITTER_ENDPOINT, '/2/users/by')
params = {'user.fields': 'public_metrics', 'usernames': self.social_account_handle}
headers = self._get_twitter_oauth_header(
twitter_account_info_url,
params=params,
method='GET',
)
result = requests.get(
twitter_account_info_url,
params=params,
headers=headers,
timeout=5
)
if isinstance(result.json(), dict) and result.json().get('errors'):
self._action_disconnect_accounts(result.json())
return False
return result.json()
def _get_last_tweets_stats(self):
""" To properly retrieve statistics and trends, we would need an Enterprise 'Engagement API' access.
See: https://developer.twitter.com/en/docs/metrics/get-tweet-engagement/overview
Since we don't have access, we use the last 100 user tweets (max for one request) to aggregate
the data we are able to retrieve. """
self.ensure_one()
tweets_endpoint_url = url_join(
self.env['social.media']._TWITTER_ENDPOINT,
'/2/users/%s/tweets' % self.twitter_user_id)
params = {
'max_results': 100,
'tweet.fields': 'public_metrics',
}
headers = self._get_twitter_oauth_header(
tweets_endpoint_url,
params=params,
method='GET'
)
result = requests.get(
tweets_endpoint_url,
params,
headers=headers,
timeout=10,
)
if isinstance(result.json(), dict) and result.json().get('errors'):
self._action_disconnect_accounts(result.json())
return False
last_tweets_stats = {
'engagement': 0,
'stories': 0
}
for tweet in result.json().get('data', []):
public_metrics = tweet.get('public_metrics', {})
last_tweets_stats['engagement'] += public_metrics.get('like_count', 0) + public_metrics.get('reply_count', 0)
last_tweets_stats['stories'] += public_metrics.get('retweet_count', 0)
return last_tweets_stats
def _get_twitter_oauth_header(self, url, headers={}, params={}, method='POST'):
self.ensure_one()
headers.update({
'oauth_token': self.twitter_oauth_token,
'oauth_token_secret': self.twitter_oauth_token_secret,
})
return self.media_id._get_twitter_oauth_header(url, headers=headers, params=params, method=method)
def _format_attachments_to_images_twitter(self, image_ids):
return self._format_images_twitter([{
'bytes': base64.decodebytes(image.datas),
'file_size': image.file_size,
'mimetype': image.mimetype
} for image in image_ids])
def _format_images_twitter(self, image_ids):
""" Twitter needs a special kind of uploading to process images.
It's done in 3 steps:
- initialize upload transaction
- send bytes
- finalize upload transaction.
More information: https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-upload.html """
self.ensure_one()
if not image_ids:
return False
media_ids = []
for image in image_ids:
media_id = self._init_twitter_upload(image)
self._process_twitter_upload(image, media_id)
self._finish_twitter_upload(media_id)
media_ids.append(media_id)
return media_ids
def _init_twitter_upload(self, image):
data = {
'total_bytes': image['file_size'],
'media_category': 'tweet_gif' if image['mimetype'] == 'image/gif' else 'tweet_image',
'media_type': image['mimetype'],
}
url_endpoint = f"{TWITTER_IMAGES_UPLOAD_ENDPOINT}/initialize"
headers = self._get_twitter_oauth_header(url_endpoint)
result = requests.post(url_endpoint, json=data, headers=headers, timeout=5)
if not result.ok:
# unfortunately Twitter does not return a proper error code so we have to rely on the error message
# last known max file size for the API is 20MB
generic_api_error = result.json().get('error', '')
raise UserError(_("We could not upload your image, it may be corrupted, it may exceed size limit or API may have send improper response (error: %s).", generic_api_error))
return result.json().get('data').get('id')
def _process_twitter_upload(self, image, media_id):
files = {'media': image['bytes']}
data = {"segment_index": 0}
url_endpoint = f"{TWITTER_IMAGES_UPLOAD_ENDPOINT}/{media_id}/append"
headers = self._get_twitter_oauth_header(url_endpoint)
requests.post(url_endpoint, files=files, data=data, headers=headers, timeout=15)
def _finish_twitter_upload(self, media_id):
url_endpoint = f"{TWITTER_IMAGES_UPLOAD_ENDPOINT}/{media_id}/finalize"
headers = self._get_twitter_oauth_header(url_endpoint)
requests.post(url_endpoint, headers=headers, timeout=5)
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import logging
import re
import requests
from odoo import models, fields, api
from odoo.exceptions import UserError
from odoo.tools.urls import urljoin as url_join
_logger = logging.getLogger(__name__)
class SocialLivePost(models.Model):
_inherit = 'social.live.post'
twitter_tweet_id = fields.Char('X post id')
def _compute_live_post_link(self):
twitter_live_posts = self._filter_by_media_types(['twitter']).filtered(
lambda post: post.state == 'posted' and post.account_id.twitter_user_id
)
super(SocialLivePost, (self - twitter_live_posts))._compute_live_post_link()
for post in twitter_live_posts:
post.live_post_link = 'https://www.twitter.com/%s/statuses/%s' % (
post.account_id.twitter_user_id, post.twitter_tweet_id
)
def _refresh_statistics(self):
super()._refresh_statistics()
accounts = self.env['social.account'].search([('media_type', '=', 'twitter')])
for account in accounts:
existing_live_posts = self.env['social.live.post'].sudo().search(
[('account_id', '=', account.id), ('twitter_tweet_id', '!=', False)],
order='create_date DESC', limit=100)
if not existing_live_posts:
continue
tweets_endpoint_url = url_join(self.env['social.media']._TWITTER_ENDPOINT, '/2/tweets')
query_params = {
'ids': ','.join(existing_live_posts.mapped('twitter_tweet_id')),
'tweet.fields': 'public_metrics',
}
headers = account._get_twitter_oauth_header(
tweets_endpoint_url,
params=query_params,
method='GET'
)
result = requests.get(
tweets_endpoint_url,
params=query_params,
headers=headers,
timeout=5
)
if not result.ok:
_logger.warning('Failed to fetch the account (%i) metrics: %r.', account.id, result.text)
result_tweets = result.json().get('data', [])
if isinstance(result_tweets, dict) and result_tweets.get('errors'):
account._action_disconnect_accounts(result_tweets)
return
existing_live_posts_by_tweet_id = {
live_post.twitter_tweet_id: live_post for live_post in existing_live_posts
}
for tweet in result_tweets:
existing_live_post = existing_live_posts_by_tweet_id.get(tweet.get('id'))
if existing_live_post:
public_metrics = tweet.get('public_metrics', {})
likes_count = public_metrics.get('like_count', 0)
comments_count = public_metrics.get('reply_count', 0)
retweets_count = public_metrics.get('retweet_count', 0)
existing_live_post.engagement = likes_count + comments_count + retweets_count
def _post(self):
twitter_live_posts = self._filter_by_media_types(['twitter'])
super(SocialLivePost, (self - twitter_live_posts))._post()
twitter_live_posts._post_twitter()
def _post_twitter(self):
post_endpoint_url = url_join(self.env['social.media']._TWITTER_ENDPOINT, '/2/tweets')
for live_post in self:
account = live_post.account_id
params = {
'text': live_post.message,
}
try:
images_attachments_ids = account._format_attachments_to_images_twitter(live_post.image_ids)
except UserError as e:
live_post.write({
'state': 'failed',
'failure_reason': str(e)
})
continue
if images_attachments_ids:
params['media'] = {'media_ids': images_attachments_ids}
headers = account._get_twitter_oauth_header(post_endpoint_url)
result = requests.post(
post_endpoint_url,
json=params,
headers=headers,
timeout=5
)
if result.ok:
live_post.twitter_tweet_id = result.json()['data']['id']
values = {
'state': 'posted',
'failure_reason': False
}
else:
values = {
'state': 'failed',
'failure_reason': result.text
}
live_post.write(values)
@api.model
def _remove_mentions(self, message, ignore_mentions=None):
"""Remove mentions in the Tweet message.
:param message: text message in which we will look for mention
:param ignore_mentions: do not remove those mentions if found
"""
if not ast.literal_eval(self.env['ir.config_parameter'].sudo().get_param(
'social_twitter.disable_mentions', 'False')):
return message
if ignore_mentions:
# keep only safe (mention consistent) chars in `ignore_mention`
ignore_mentions = [
re.sub(r'[^\w]', '', mention).lower()
for mention in ignore_mentions
]
mention_regex = r'(^|[^\w#])@(%s)\b'
remove_mentions = [
match[2] for match in re.finditer(mention_regex % r'\w+', message)
if not ignore_mentions or match[2].lower() not in ignore_mentions
]
for mention in remove_mentions:
message = re.sub(mention_regex % mention, r'\1@ \2', message)
return message
+254
View File
@@ -0,0 +1,254 @@
# -*- 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
+34
View File
@@ -0,0 +1,34 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.fields import Domain
class SocialPost(models.Model):
_inherit = 'social.post'
twitter_image_ids = fields.Many2many(relation="twitter_image_ids_rel")
@api.depends('live_post_ids.twitter_tweet_id')
def _compute_stream_posts_count(self):
super()._compute_stream_posts_count()
@api.depends('state')
def _compute_twitter_post_limit_message(self):
self.twitter_post_limit_message = False
self.is_twitter_post_limit_exceed = False
super(SocialPost, self - self.filtered(lambda post: post.state in ['posting', 'posted']))._compute_twitter_post_limit_message()
def _get_stream_post_domain(self):
domain = super()._get_stream_post_domain()
twitter_tweet_ids = [twitter_tweet_id for twitter_tweet_id in self.live_post_ids.mapped('twitter_tweet_id') if twitter_tweet_id]
if twitter_tweet_ids:
return Domain.OR([domain, [('twitter_tweet_id', 'in', twitter_tweet_ids)]])
return domain
@api.model
def _prepare_post_content(self, message, media_type, **kw):
message = super()._prepare_post_content(message, media_type, **kw)
if message and media_type == 'twitter':
message = self.env["social.live.post"]._remove_mentions(message)
return message
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from urllib.parse import urlparse
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class SocialPostTemplate(models.Model):
_inherit = 'social.post.template'
twitter_message = fields.Text(
'X Message', compute='_compute_message_by_media',
store=True, readonly=False)
twitter_image_ids = fields.Many2many(
'ir.attachment', 'template_twitter_image_ids_rel', string='X Images',
help='Will attach images to your posts.',
compute='_compute_images_by_media', store=True, readonly=False, bypass_search_access=True)
twitter_preview = fields.Html('X Preview', compute='_compute_twitter_preview')
has_twitter_account = fields.Boolean('Has X Account', compute='_compute_has_twitter_account')
display_twitter_preview = fields.Boolean('Display X Preview', compute='_compute_display_twitter_preview')
twitter_post_limit_message = fields.Char('X Post Limit Message', compute="_compute_twitter_post_limit_message")
is_twitter_post_limit_exceed = fields.Boolean('X Post Limit Exceeded', compute="_compute_twitter_post_limit_message")
@api.constrains('twitter_message', 'twitter_image_ids')
def _check_has_twitter_message_or_image(self):
for post in self:
if (post.has_twitter_account
and not post.twitter_message
and not post.twitter_image_ids):
raise UserError(_("Please specify either an X Message or upload some X Images.", post.id))
@api.depends('account_ids.media_id.media_type')
def _compute_has_twitter_account(self):
for post in self:
post.has_twitter_account = 'twitter' in post.account_ids.media_id.mapped('media_type')
@api.depends('has_twitter_account', 'twitter_message', 'twitter_image_ids')
def _compute_display_twitter_preview(self):
for post in self:
post.display_twitter_preview = post.has_twitter_account and (post.twitter_message or post.twitter_image_ids)
@api.depends('twitter_message', 'has_twitter_account')
def _compute_twitter_post_limit_message(self):
self.twitter_post_limit_message = False
self.is_twitter_post_limit_exceed = False
for post in self.filtered('has_twitter_account'):
message_length = len(post.twitter_message or '')
twitter_account = post.account_ids._filter_by_media_types(['twitter'])
post.twitter_post_limit_message = _("%(current_length)s / %(max_length)s characters to fit in a Post", current_length=message_length, max_length=twitter_account.media_id.max_post_length)
post.is_twitter_post_limit_exceed = twitter_account.media_id.max_post_length and message_length > twitter_account.media_id.max_post_length
@api.depends(lambda self: ['twitter_message', 'twitter_image_ids', 'is_twitter_post_limit_exceed', 'has_twitter_account'] + self._get_post_message_modifying_fields())
def _compute_twitter_preview(self):
self.twitter_preview = False
for post in self.filtered('has_twitter_account'):
twitter_account = post.account_ids._filter_by_media_types(['twitter'])
image_urls = []
link_preview = {}
if post.twitter_image_ids:
image_urls = [
f'/web/image/{image._origin.id or image.id}'
for image in post.twitter_image_ids.sorted(lambda image: image._origin.id or image.id, reverse=True)
]
elif url := self.env["social.post"]._extract_url_from_message(post.twitter_message):
preview = self.env["mail.link.preview"].sudo()._search_or_create_from_url(url)
link_preview["url"] = url
link_preview["domain"] = urlparse(url).hostname
if image_url := preview.og_image:
image_urls.append(image_url)
post.twitter_preview = self.env['ir.qweb']._render('social_twitter.twitter_preview', {
**post._prepare_preview_values('twitter'),
'message': post._prepare_post_content(
post.twitter_message,
'twitter',
**{field: post[field] for field in post._get_post_message_modifying_fields()}),
'image_urls': image_urls,
'limit': twitter_account.media_id.max_post_length,
'is_twitter_post_limit_exceed': post.is_twitter_post_limit_exceed,
'link_preview': link_preview,
})
@api.model
def _message_fields(self):
"""Return the message field per media."""
return {**super()._message_fields(), 'twitter': 'twitter_message'}
@api.model
def _images_fields(self):
"""Return the images field per media."""
return {**super()._images_fields(), 'twitter': 'twitter_image_ids'}
+228
View File
@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import dateutil.parser
import logging
import requests
from html import unescape
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from odoo.tools.urls import urljoin as url_join
_logger = logging.getLogger(__name__)
class SocialStream(models.Model):
_inherit = 'social.stream'
twitter_searched_keyword = fields.Char('Search Keyword')
twitter_followed_account_search = fields.Char('Search User')
twitter_account_user_id = fields.Char(related="account_id.twitter_user_id")
# TODO awa: clean unused 'social.twitter.account' in a cron job
twitter_followed_account_id = fields.Many2one('social.twitter.account')
@api.constrains('stream_type_id', 'twitter_followed_account_id')
def _check_twitter_followed_account_id(self):
if any(
stream.stream_type_id.stream_type in ('twitter_follow', 'twitter_likes')
and not stream.twitter_followed_account_id
for stream in self
):
raise UserError(_("Please select a X account for this stream type."))
def _apply_default_name(self):
twitter_streams = self.filtered(lambda s: s.media_id.media_type == 'twitter')
super(SocialStream, (self - twitter_streams))._apply_default_name()
for stream in twitter_streams:
name = False
if stream.stream_type_id.stream_type in ['twitter_follow', 'twitter_likes'] and stream.twitter_followed_account_id:
name = '%s: %s' % (stream.stream_type_id.name, stream.twitter_followed_account_id.name)
elif stream.stream_type_id.stream_type == 'twitter_user_mentions' and stream.account_id:
name = '%s: %s' % (stream.stream_type_id.name, stream.account_id.name)
elif stream.stream_type_id.stream_type == 'twitter_keyword' and stream.twitter_searched_keyword:
name = '%s: %s' % (stream.stream_type_id.name, stream.twitter_searched_keyword)
if name:
stream.write({'name': name})
def _fetch_stream_data(self):
if self.media_id.media_type != 'twitter':
return super()._fetch_stream_data()
if self.stream_type_id.stream_type == 'twitter_user_mentions':
return self._fetch_tweets('/2/users/%s/mentions' % self.account_id.twitter_user_id)
if self.stream_type_id.stream_type == 'twitter_follow':
return self._fetch_tweets('/2/users/%s/tweets' % self.twitter_followed_account_id.twitter_id)
if self.stream_type_id.stream_type == 'twitter_likes':
return self._fetch_tweets('/2/users/%s/liked_tweets' % self.twitter_followed_account_id.twitter_id)
if self.stream_type_id.stream_type == 'twitter_keyword':
keyword = self.twitter_searched_keyword
if not keyword.startswith("#"):
keyword = "#%s" % keyword
return self._fetch_tweets('/2/tweets/search/recent', {'query': keyword + ' -is:retweet'})
def _fetch_tweets(self, endpoint_name, extra_params={}):
self.ensure_one()
query_params = {
'max_results': 100,
'tweet.fields': 'created_at,public_metrics,referenced_tweets,conversation_id',
'expansions': 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'user.fields': 'id,name,username,profile_image_url',
'media.fields': 'type,url,preview_image_url',
}
query_params.update(extra_params)
tweets_endpoint_url = url_join(self.env['social.media']._TWITTER_ENDPOINT, endpoint_name)
# TODO awa: check the "TE" header (Transfer-Encoding) to get a (smaller) gzip response
headers = self.account_id._get_twitter_oauth_header(
tweets_endpoint_url,
params=query_params,
method='GET',
)
response = requests.get(
tweets_endpoint_url,
query_params,
headers=headers,
timeout=5
)
result = response.json()
if not response.ok:
_logger.warning('Failed to fetch social stream posts: %r for account %i.', response.text, self.account_id.id)
# an error occurred
if 'Not authorized' in result.get('title', ''):
# no error code is returned by the Twitter API in that case
# it's probably because the Twitter account we tried to add
# is private
error_message = _(
"You cannot create a Stream from this X account.\n"
"It may be because it's protected. To solve this, please make sure you follow it before trying again."
)
elif response.status_code == 400 and result.get('errors', [{}])[0].get('parameters', {}).get('query'):
# invalid query
error_message = _("The keyword you've typed in does not look valid. Please try again with other words.")
else:
error_code = result.get('status')
error_message = result.get('title')
ERROR_MESSAGES = {
429: _("Looks like you've made too many requests. Please wait a few minutes before giving it another try."),
}
error_message = ERROR_MESSAGES.get(error_code, error_message)
if error_message:
raise UserError(error_message)
if isinstance(result, dict) and not result.get('data') and result.get('errors') or result is None:
self.account_id._action_disconnect_accounts(result)
return False
tweets_by_tweet_id = {
tweet['id']: tweet
for tweet in result.get('data', [])
}
existing_tweets = self.env['social.stream.post'].sudo().search([
('stream_id', '=', self.id),
('twitter_tweet_id', 'in', list(tweets_by_tweet_id)),
])
existing_tweets_by_tweet_id = {
tweet.twitter_tweet_id: tweet for tweet in existing_tweets
}
# TODO awa: handle deleted tweets ?
tweets_to_create = []
users_per_id = {
user['id']: user
for user in result.get('includes', {}).get('users', [])
}
medias_per_id = {
media['media_key']: media
for media in result.get('includes', {}).get('media', [])
}
quote_and_retweet_per_ids = {
tweet.get('id'): tweet
for tweet in result.get('includes', {}).get('tweets', [])
}
for twitter_tweet_id, tweet in tweets_by_tweet_id.items():
public_metrics = tweet.get('public_metrics', {})
user_info = users_per_id.get(tweet.get('author_id'), {})
created_date = tweet.get('created_at')
if created_date:
created_date = fields.Datetime.from_string(dateutil.parser.parse(created_date).strftime('%Y-%m-%d %H:%M:%S'))
values = {
'stream_id': self.id,
'message': unescape(tweet.get('text', '')),
'author_name': user_info.get('name'),
'published_date': created_date,
'twitter_likes_count': public_metrics.get('like_count'),
'twitter_comments_count': public_metrics.get('reply_count'),
'twitter_retweet_count': public_metrics.get('retweet_count'),
'twitter_tweet_id': twitter_tweet_id,
'twitter_conversation_id': tweet.get('conversation_id'),
'twitter_author_id': tweet.get('author_id'),
'twitter_screen_name': user_info.get('username'),
'twitter_profile_image_url': user_info.get('profile_image_url'),
}
# Handle quote and retweet
referenced_tweets = tweet.get('referenced_tweets', [])
if referenced_tweets and referenced_tweets[0]['type'] == 'retweeted':
values['twitter_retweeted_tweet_id_str'] = referenced_tweets[0]['id']
elif referenced_tweets and referenced_tweets[0]['type'] == 'quoted':
quote = quote_and_retweet_per_ids.get(referenced_tweets[0]['id'], {})
quote_author = users_per_id.get(quote.get('author_id'), {})
values.update({
'twitter_quoted_tweet_id_str': quote.get('id'),
'twitter_quoted_tweet_message': quote.get('text', ''),
'twitter_quoted_tweet_author_name': quote_author.get('name', ''),
'twitter_quoted_tweet_profile_image_url': quote_author.get('profile_image_url', ''),
})
if quote_author.get('username'):
values['twitter_quoted_tweet_author_link'] = 'https://twitter.com/%s' % quote_author['username']
retweets = list(filter(lambda ref: ref.get('type') == 'retweeted', referenced_tweets))
if retweets:
origin_tweet_msg = quote_and_retweet_per_ids.get(retweets[0].get('id'), {}).get('text')
if origin_tweet_msg:
username = users_per_id[quote_and_retweet_per_ids.get(retweets[0].get('id'), {}).get('author_id')].get('username', _('Unknown'))
values['message'] = unescape(
f"RT @{username}: "
f"{origin_tweet_msg}"
)
# For retweets, it is more interesting to know the likes number of the actual post
# instead of the retweet. As this number is less likely to change a lot, contrary to
# the original post.
retweet_like_count = quote_and_retweet_per_ids.get(retweets[0].get('id'), {}).get('public_metrics', {}).get('like_count', False)
if retweet_like_count is not False:
values['twitter_likes_count'] = retweet_like_count
existing_tweet = existing_tweets_by_tweet_id.get(tweet.get('id'))
if existing_tweet:
existing_tweet.sudo().write(values)
else:
# attachments are only extracted for new posts
values.update(self._extract_twitter_attachments(tweet, medias_per_id))
tweets_to_create.append(values)
stream_posts = self.env['social.stream.post'].sudo().create(tweets_to_create)
return any(stream_post.stream_id.create_uid.id == self.env.uid for stream_post in stream_posts)
@api.model
def _extract_twitter_attachments(self, tweet, medias_per_id=None):
if not medias_per_id:
return {}
medias = [
medias_per_id[media]
for media in tweet.get('attachments', {}).get('media_keys', [])
]
images = [
{'image_url': media['url']}
for media in medias
if media['type'] == 'photo'
]
return {'stream_post_image_ids': [(0, 0, attachment) for attachment in images]} if images else {}
+366
View File
@@ -0,0 +1,366 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import base64
import logging
import requests
from odoo import _, api, models, fields
from odoo.exceptions import UserError
from odoo.http import request
from odoo.tools.urls import urljoin as url_join
_logger = logging.getLogger(__name__)
class SocialStreamPost(models.Model):
_inherit = 'social.stream.post'
twitter_tweet_id = fields.Char('X Post ID', index=True)
twitter_conversation_id = fields.Char('X Conversation ID')
twitter_author_id = fields.Char('X Author ID')
twitter_screen_name = fields.Char('X Screen Name')
twitter_profile_image_url = fields.Char('X Profile Image URL')
twitter_likes_count = fields.Integer('X Likes')
twitter_user_likes = fields.Boolean('X User Likes')
twitter_comments_count = fields.Integer('X Replies')
twitter_retweet_count = fields.Integer('Reposts')
twitter_retweeted_tweet_id_str = fields.Char('X Repost ID')
twitter_can_retweet = fields.Boolean('X Repost Permission', compute='_compute_twitter_can_retweet')
twitter_quoted_tweet_id_str = fields.Char('X Quoted post ID')
twitter_quoted_tweet_message = fields.Text('Quoted post message')
twitter_quoted_tweet_author_name = fields.Char('Quoted post author Name')
twitter_quoted_tweet_author_link = fields.Char('Quoted post author Link')
twitter_quoted_tweet_profile_image_url = fields.Char('Quoted post profile image URL')
_tweet_uniq = models.Constraint(
'UNIQUE (twitter_tweet_id, stream_id)',
"You can not store two times the same post on the same stream!",
)
def _compute_author_link(self):
twitter_posts = self._filter_by_media_types(['twitter'])
super(SocialStreamPost, (self - twitter_posts))._compute_author_link()
for post in twitter_posts:
post.author_link = 'https://twitter.com/intent/user?user_id=%s' % post.twitter_author_id
def _compute_post_link(self):
twitter_posts = self._filter_by_media_types(['twitter'])
super(SocialStreamPost, (self - twitter_posts))._compute_post_link()
for post in twitter_posts:
post.post_link = 'https://www.twitter.com/%s/statuses/%s' % (post.twitter_author_id, post.twitter_tweet_id)
@api.depends('twitter_retweeted_tweet_id_str', 'twitter_tweet_id')
def _compute_twitter_can_retweet(self):
tweets = self.filtered(lambda post: post.twitter_tweet_id)
(self - tweets).twitter_can_retweet = False
if not tweets:
return
tweet_ids = set(tweets.mapped('twitter_tweet_id')) | set(tweets.mapped('twitter_retweeted_tweet_id_str'))
twitter_author_ids = set(tweets.stream_id.account_id.mapped('twitter_user_id'))
potential_retweets = self.search([
('twitter_author_id', 'in', list(twitter_author_ids)),
'|',
('twitter_tweet_id', 'in', list(tweet_ids)),
('twitter_retweeted_tweet_id_str', 'in', list(tweet_ids)),
])
for tweet in tweets:
account = tweet.stream_id.account_id
if tweet.twitter_retweeted_tweet_id_str and tweet.twitter_author_id == account.twitter_user_id:
# If the tweet is a retweet and has been posted with the given account, the user will not
# be allowed to retweet the tweet.
tweet.twitter_can_retweet = False
continue
# Otherwise, the user will be allowed to retweet the tweet if there does not exist a retweet
# of that tweet posted with the given account.
original_tweet_id = tweet.twitter_retweeted_tweet_id_str or tweet.twitter_tweet_id
tweet.twitter_can_retweet = not any(
current.twitter_retweeted_tweet_id_str == original_tweet_id and \
current.twitter_author_id == account.twitter_user_id for current in potential_retweets
)
def _compute_is_author(self):
twitter_posts = self._filter_by_media_types(['twitter'])
super(SocialStreamPost, (self - twitter_posts))._compute_is_author()
for post in twitter_posts:
post.is_author = post.twitter_author_id == post.account_id.twitter_user_id
# ========================================================
# COMMENTS / LIKES
# ========================================================
def _twitter_comment_add(self, stream, comment_id, message, attachment):
"""Create a reply to a tweet."""
self.ensure_one()
tweet_id = comment_id or self.twitter_tweet_id
tweet = self._twitter_post_tweet(stream, message, attachment, reply={'in_reply_to_tweet_id': tweet_id})
tweet['in_reply_to_tweet_id'] = tweet_id
return tweet
def _twitter_comment_fetch(self, page=1):
"""Find the tweets in the same thread, but after the current one.
All tweets have a `conversation_id` field, which correspond to the first tweet
in the same thread. "comments" do not really exist in Twitter, so we take all
the tweet in the same thread (same `conversation_id`), after the current one.
https://developer.twitter.com/en/docs/twitter-api/tweets/search/integrate/build-a-query
"""
self.ensure_one()
if not self.twitter_conversation_id:
raise UserError(_('This post is outdated, please refresh the stream and try again.'))
endpoint_url = url_join(self.env['social.media']._TWITTER_ENDPOINT, '/2/tweets/search/recent')
query_params = {
'query': f'conversation_id:{self.twitter_conversation_id}',
'since_id': self.twitter_tweet_id,
'max_results': 100,
'tweet.fields': 'conversation_id,created_at,public_metrics,referenced_tweets',
'expansions': 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'user.fields': 'id,name,username,profile_image_url',
'media.fields': 'type,url,preview_image_url',
}
headers = self.stream_id.account_id._get_twitter_oauth_header(
endpoint_url,
params=query_params,
method='GET',
)
result = requests.get(
endpoint_url,
params=query_params,
headers=headers,
timeout=10,
)
if not result.ok:
if result.json().get('errors', [{}])[0].get('parameters', {}).get('since_id'):
raise UserError(_("See older replies on x.com"))
raise UserError(_("Failed to fetch the posts in the same thread: '%(text)s' using the account %(account)s.", text=result.text, account=self.stream_id.account_id.name))
users = {
user['id']: {
**user,
'profile_image_url': user.get('profile_image_url'),
}
for user in result.json().get('includes', {}).get('users', [])
}
medias = {
media['media_key']: media
for media in result.json().get('includes', {}).get('media', [])
}
conversation_thread_tweets = result.json().get('includes', {}).get('tweets', ())
conversation_thread_tweets = {tweet['id']: tweet for tweet in conversation_thread_tweets}
return {
'comments': [
self.env['social.media']._format_tweet({
**tweet,
'author': users.get(tweet.get('author_id'), {}),
'medias': [
medias.get(media)
for media in tweet.get('attachments', {}).get('media_keys', [])
],
'conversation_thread_tweets': conversation_thread_tweets,
'users': users,
'social_account_handle': self.account_id.social_account_handle,
})
for tweet in result.json().get('data', [])
],
'is_reply_limited': ast.literal_eval(self.env['ir.config_parameter'].sudo().get_param(
'social_twitter.enable_reply_limit', 'False'))
}
def _twitter_tweet_delete(self, tweet_id):
self.ensure_one()
delete_endpoint = url_join(
self.env['social.media']._TWITTER_ENDPOINT,
'/2/tweets/%s' % tweet_id)
headers = self.stream_id.account_id._get_twitter_oauth_header(
delete_endpoint,
method='DELETE',
)
response = requests.delete(
delete_endpoint,
headers=headers,
timeout=5
)
if not response.ok:
raise UserError(_('Failed to delete the post\n%s.', response.text))
return True
def _twitter_tweet_like(self, stream, tweet_id, like):
if like:
endpoint = url_join(
request.env['social.media']._TWITTER_ENDPOINT,
'/2/users/%s/likes' % stream.account_id.twitter_user_id)
headers = stream.account_id._get_twitter_oauth_header(endpoint)
result = requests.post(
endpoint,
json={'tweet_id': tweet_id},
headers=headers,
timeout=5,
)
else:
endpoint = url_join(
request.env['social.media']._TWITTER_ENDPOINT,
'/2/users/%s/likes/%s' % (stream.account_id.twitter_user_id, tweet_id))
headers = stream.account_id._get_twitter_oauth_header(endpoint, method='DELETE')
result = requests.delete(endpoint, headers=headers, timeout=10)
if not result.ok:
raise UserError(_('Can not like / unlike the tweet\n%s.', result.text))
return True
def _twitter_update_likes(self, like):
"""Like the Tweet in SUDO for social users."""
for post in self:
if post.twitter_user_likes == like:
continue
post.sudo().write({
'twitter_likes_count': max(0, post.twitter_likes_count + (1 if like else -1)),
'twitter_user_likes': like,
})
def _twitter_do_retweet(self):
""" Creates a new retweet for the given stream post on Twitter. """
if not self.twitter_can_retweet:
raise UserError(_('A repost already exists'))
account = self.stream_id.account_id
retweet_endpoint = url_join(self.env['social.media']._TWITTER_ENDPOINT, '/2/users/%s/retweets' % account.twitter_user_id)
headers = account._get_twitter_oauth_header(retweet_endpoint)
result = requests.post(retweet_endpoint, headers=headers, json={'tweet_id': self.twitter_tweet_id}, timeout=5)
if result.ok: # 200-series HTTP code
return True
elif result.status_code == 401:
account.write({'is_media_disconnected': True})
raise UserError(_('You are not authenticated'))
error = result.json().get('detail')
if error:
raise UserError(error)
raise UserError(_('Unknown error'))
def _twitter_undo_retweet(self):
""" Deletes the retweet of the given stream post from Twitter """
tweet_id = self.twitter_retweeted_tweet_id_str or self.twitter_tweet_id
account = self.stream_id.account_id
unretweet_endpoint = url_join(
self.env['social.media']._TWITTER_ENDPOINT,
'/2/users/%s/retweets/%s' % (account.twitter_user_id, tweet_id),
)
headers = account._get_twitter_oauth_header(unretweet_endpoint, method='DELETE')
result = requests.delete(unretweet_endpoint, headers=headers, timeout=5)
if result.status_code == 401:
account.write({'is_media_disconnected': True})
raise UserError(_('You are not authenticated'))
if not result.ok or result.json().get('data', {}).get('retweeted') is not False:
error = result.json().get('detail')
if error:
raise UserError(error)
raise UserError(_('Unknown error'))
retweets = self.search([
('twitter_author_id', '=', self.stream_id.account_id.twitter_user_id),
('twitter_retweeted_tweet_id_str', '=', tweet_id),
])
retweets.unlink()
return True
def _twitter_tweet_quote(self, message, attachment):
"""
:param werkzeug.datastructures.FileStorage attachment:
Creates a new quotes for the current stream post on Twitter.
If the stream post does not have any message, a retweet will be created instead of a quote.
"""
self.ensure_one()
if not message and not attachment:
return self._twitter_do_retweet()
self._twitter_post_tweet(self.stream_id, message, attachment, quote_tweet_id=self.twitter_tweet_id)
return True
# ========================================================
# UTILITY / MISC
# ========================================================
def _fetch_matching_post(self):
self.ensure_one()
if self.account_id.media_type == 'twitter' and self.twitter_tweet_id:
return self.env['social.live.post'].search(
[('twitter_tweet_id', '=', self.twitter_tweet_id)], limit=1
).post_id
return super()._fetch_matching_post()
def _twitter_post_tweet(self, stream, message, attachment, **additionnal_params):
data = {
'text': message,
**additionnal_params,
}
images_attachments_ids = None
if attachment:
bytes_data = attachment.read()
images_attachments_ids = stream.account_id._format_images_twitter([{
'bytes': bytes_data,
'file_size': len(bytes_data),
'mimetype': attachment.content_type,
}])
if images_attachments_ids:
data['media'] = {'media_ids': images_attachments_ids}
post_endpoint_url = url_join(request.env['social.media']._TWITTER_ENDPOINT, '/2/tweets')
headers = stream.account_id._get_twitter_oauth_header(post_endpoint_url)
result = requests.post(
post_endpoint_url,
json=data,
headers=headers,
timeout=5
)
if not result.ok:
stream.account_id.write({'is_media_disconnected': True})
error = result.json().get('detail') or result.text
raise UserError(_('Failed to post comment: %(error)s with the account %(account)s.', error=error, account=stream.account_id.name))
tweet = result.json()['data']
# we can not use fields expansion when creating a tweet,
# so we fill manually the missing values to not recall the API
tweet.update({
'author_id': self.account_id.twitter_user_id,
'author': {
'id': self.account_id.twitter_user_id,
'name': self.account_id.name,
'username': self.account_id.social_account_handle,
'profile_image_url': '/web/image/social.account/%s/image' % stream.account_id.id,
},
**additionnal_params,
})
if images_attachments_ids:
# the image didn't create an attachment, and it will require an extra
# API call to get the URL, so we just base 64 encode the image data
b64_image = base64.b64encode(bytes_data).decode()
link = "data:%s;base64,%s" % (attachment.content_type, b64_image)
tweet['medias'] = [{'url': link, 'type': 'photo'}]
tweet['social_account_handle'] = self.account_id.social_account_handle
return request.env['social.media']._format_tweet(tweet)
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class SocialTwitterAccount(models.Model):
_name = 'social.twitter.account'
_description = 'Social X Account'
name = fields.Char('Name')
description = fields.Char('Description')
twitter_id = fields.Char('X ID')
image = fields.Binary('Image')
twitter_searched_by_id = fields.Many2one('social.account', 'Searched by')