def update_twitter_link_length(self): if time.time() - self.last_url_len_update > 60 * 60 * 24: self.twitter_api._config = None self.url_length = max(self.twitter_api.GetShortUrlLength(False), self.twitter_api.GetShortUrlLength(True)) + 1 self.last_url_len_update = time.time() lgt(f'Updated expected short URL length - it is now {self.url_length} characters.')
def init_process(self): try: self.since_toot_id = self.mastodon_api.account_statuses(self.ma_account_id)[0]["id"] lgt(f'Tweeting any toot after toot {self.since_toot_id}') except IndexError: lgt('Tweeting any toot (user timeline is empty right now)') self.update_twitter_link_length()
def run(self): self.init_process() lgt('Listening for tweets…') for tweet in self.twitter_api.GetUserStream(): if 'text' not in tweet and 'full_text' not in tweet: continue # Avoids a race condition. # We wait a little bit so toots sent to Twitter # can be marked as such before this run, avoiding # bouncing tweets/toots. time.sleep(config.STATUS_PROCESS_DELAY) tweet_id = tweet['id'] if self.is_tweet_sent_by_us(tweet_id): continue if tweet['user']['id_str'] != str(self.tw_account_id): continue is_retweet = False content = MastodonPublisher._get_tweet_full_text(tweet) if 'retweeted_status' in tweet: rt = tweet['retweeted_status'] rt_content = MastodonPublisher._get_tweet_full_text(rt) content = f'\U0001f501 RT @{rt["user"]["screen_name"]}\n\n' \ f'{rt_content}\n\n' \ f'https://twitter.com/{rt["user"]["screen_name"]}/status/{rt["id_str"]}' tweet = rt is_retweet = True reply_to = None with lock: if 'in_reply_to_user_id' in tweet and 'in_reply_to_status_id' in tweet and tweet['in_reply_to_user_id']: # If it's a reply, we keep the tweet if: # 1. it's a reply from us (in a thread); # 2. it's a reply from a previously transmitted tweet, so we don't sync # if someone replies to someone in two or more tweets (because in this # case the 2nd tweet and the ones after are replying to us); # 3. it's a reply from another one but we retweeted it. # If it's not a tweet in reply to us if ((tweet['in_reply_to_user_id'] != self.tw_account_id # or if it's a reply to us but not in our threads association or tweet['in_reply_to_status_id'] not in self.status_associations['t2m']) # or if it's a tweet from us but not a retweet and not is_retweet): # ... in all these cases, we don't want to transfer the tweet. lgt(f'Skipping tweet {tweet_id} - it\'s a reply.') continue # A tweet can be a reply without previous tweet if we directly mentioned someone # (starting the tweet with the mention). if tweet['in_reply_to_status_id'] is not None: reply_to = self.status_associations['t2m'].get(tweet['in_reply_to_status_id']) media_attachments = (tweet['media'] if 'media' in tweet else tweet['entities']['media'] if 'entities' in tweet and 'media' in tweet['entities'] else tweet['extended_tweet']['entities']['media'] if 'extended_tweet' in tweet and 'entities' in tweet['extended_tweet'] and 'media' in tweet['extended_tweet']['entities'] else []) urls = (tweet['urls'] if 'urls' in tweet else tweet['entities']['urls'] if 'entities' in tweet and 'urls' in tweet['entities'] else tweet['extended_tweet']['entities']['urls'] if 'extended_tweet' in tweet and 'entities' in tweet['extended_tweet'] and 'urls' in tweet['extended_tweet']['entities'] else []) sensitive = tweet['possibly_sensitive'] if 'possibly_sensitive' in tweet else False content_toot = html.unescape(content) mentions = re.findall(r'@[a-zA-Z0-9_]*', content_toot) cws = config.TWEET_CW_REGEXP.findall(content) if config.TWEET_CW_REGEXP else [] warning = None media_ids = [] if mentions: for mention in mentions: # Replace all mentions for an equivalent to clearly signal their origin on Twitter content_toot = re.sub(mention, mention + '@twitter.com', content_toot) if urls: for url in urls: # Un-shorten URLs content_toot = re.sub(url['url'], url['expanded_url'], content_toot) if cws: warning = (config.TWEET_CW_SEPARATOR.join([cw.strip() for cw in cws]) if config.TWEET_CW_ALLOW_MULTI else cws[0].strip()) content_toot = config.TWEET_CW_REGEXP.sub('', content_toot, count=(0 if config.TWEET_CW_ALLOW_MULTI else 1)).strip() if media_attachments: for attachment in media_attachments: # Remove the t.co link to the media content_toot = re.sub(attachment['url'], '', content_toot) attachment_url = (attachment['media_url_https'] if 'media_url_https' in attachment else attachment['media_url']) media_ids.append(self.transfer_media( media_url=attachment_url, to='mastodon' )) # Now that the toot is ready, we send it. try: retry_counter = 0 post_success = False lgt(f'Sending toot "{content_toot.strip()}"…') while not post_success: try: if len(media_ids) == 0: try: post = self.mastodon_api.status_post( content_toot, visibility=config.TOOT_VISIBILITY, spoiler_text=warning, in_reply_to_id=reply_to ) self.mark_toot_sent(post['id']) except MastodonAPIError: # If the toot we are replying to has been deleted while we were processing it post = self.mastodon_api.status_post( content_toot, visibility=config.TOOT_VISIBILITY, spoiler_text=warning ) self.mark_toot_sent(post['id']) since_toot_id = post['id'] post_success = True else: try: post = self.mastodon_api.status_post( content_toot, media_ids=media_ids, visibility=config.TOOT_VISIBILITY, sensitive=sensitive, spoiler_text=warning, in_reply_to_id=reply_to ) self.mark_toot_sent(post['id']) except MastodonAPIError: # If the toot we are replying to has been deleted (same as before) post = self.mastodon_api.status_post( content_toot, media_ids=media_ids, visibility=config.TOOT_VISIBILITY, sensitive=sensitive, spoiler_text=warning ) self.mark_toot_sent(post['id']) since_toot_id = post['id'] post_success = True except MastodonError: if retry_counter < config.TWITTER_RETRIES: lgt('We were unable to send the toot. ' f'Retrying… ({retry_counter+1}/{config.TWITTER_RETRIES})') retry_counter += 1 time.sleep(config.TWITTER_RETRY_DELAY) else: raise lgt('Toot sent successfully.') with lock: self.associate_status(since_toot_id, tweet_id) self.save_status_associations() except MastodonError: lgt(f'Encountered error after {config.TWITTER_RETRIES} retries. Not retrying.') # Broad exception to avoid thread interruption in case of network problems or anything else. except Exception as e: lgt('Unhandled exception happened - giving up on this toot.') lgt(e)
def init_process(self): try: self.since_tweet_id = self.twitter_api.GetUserTimeline()[0].id lgt('Tooting any tweet after tweet {}'.format(self.since_tweet_id)) except IndexError: lgt('Tooting any tweet (user timeline is empty right now)')
def on_update(self, toot): # We only transfer our own toots, but the streaming endpoint receives the whole # timeline. if not self.publisher.is_from_us(toot['account']): return # Avoids a race condition. # We wait a little bit so tweets sent to Mastodon # can be marked as such before this run, avoiding # bouncing tweets/toots. time.sleep(config.STATUS_PROCESS_DELAY) toot_id = toot["id"] if self.publisher.is_toot_sent_by_us(toot_id): return if toot['visibility'] not in config.TOOT_VISIBILITY_REQUIRED_TO_TRANSFER: lgt(f'Skipping toot {toot["id"]} - invalid visibility ({toot["visibility"]})' ) return content = toot["content"] media_attachments = toot["media_attachments"] if toot['reblogged'] and 'reblog' in toot: reblog = toot['reblog'] reblog_name = f'@{reblog["account"]["username"]}@{urlparse(reblog["account"]["url"]).netloc}' content = f'\U0001f501 RT {reblog_name}\n' \ f'{reblog["content"]}\n\n' \ f'{reblog["url"]}' media_attachments = reblog["media_attachments"] toot = reblog # We trust mastodon to return valid HTML content_clean = re.sub(r'<a [^>]*href="([^"]+)">[^<]*</a>', '\g<1>', content) # We replace html br with new lines content_clean = "\n".join( re.compile(r'<br ?/?>', re.IGNORECASE).split(content_clean)) # We must also replace new paragraphs with double line skips content_clean = "\n\n".join( re.compile(r'</p><p>', re.IGNORECASE).split(content_clean)) # Then we can delete the other html contents and unescape the string content_clean = html.unescape( str(re.compile(r'<.*?>').sub("", content_clean).strip())) # Trim out media URLs content_clean = re.sub(self.publisher.MEDIA_REGEXP, "", content_clean) # Don't cross-post replies if len(content_clean) != 0 and content_clean[0] == '@': lgt('Skipping toot "' + content_clean + '" - is a reply.') return if config.TWEET_CW_PREFIX and toot['spoiler_text']: content_clean = config.TWEET_CW_PREFIX.format( toot['spoiler_text']) + content_clean content_parts = split_status(status=content_clean, max_length=280, split=config.SPLIT_ON_TWITTER, url=toot['uri']) # Tweet all the parts. On error, give up and go on with the next toot. try: reply_to = None # We check if this toot is a reply to a previously sent toot. # If so, the first corresponding tweet will be a reply to # the stored tweet. # Unlike in the Mastodon API calls, we don't have to handle the # case where the tweet was deleted, as twitter will ignore # the in_reply_to_status_id option if the given tweet # does not exists. if toot['in_reply_to_id'] in self.publisher.status_associations[ 'm2t']: reply_to = self.publisher.status_associations['m2t'][ toot['in_reply_to_id']] for i in range(len(content_parts)): media_ids = [] content_tweet = content_parts[i] # Last content part: Upload media, no -- at the end if i == len(content_parts) - 1: for attachment in media_attachments: media_ids.append( self.publisher.transfer_media( media_url=attachment["url"], to='twitter')) content_tweet = content_parts[i] # Some final cleaning content_tweet = content_tweet.strip() # Retry three times before giving up retry_counter = 0 post_success = False lgt(f'Sending tweet "{content_tweet}"…') while not post_success: try: if len(media_ids) == 0: reply_to = self.publisher.twitter_api.PostUpdate( content_tweet, in_reply_to_status_id=reply_to).id self.publisher.mark_tweet_sent(reply_to) since_tweet_id = reply_to post_success = True else: reply_to = self.publisher.twitter_api.PostUpdate( content_tweet, media=media_ids, in_reply_to_status_id=reply_to).id self.publisher.mark_tweet_sent(reply_to) since_tweet_id = reply_to post_success = True except TwitterError: if retry_counter < config.MASTODON_RETRIES: retry_counter += 1 time.sleep(config.MASTODON_RETRY_DELAY) else: raise lgt('Tweet sent successfully.') # Only the last tweet is linked to the toot, see comment # above the status_associations declaration if i == len(content_parts) - 1: with lock: self.publisher.associate_status( toot_id, since_tweet_id) self.publisher.save_status_associations() except Exception as e: lgt("Encountered error after " + str(config.MASTODON_RETRIES) + " retries. Not retrying.") print(e) # From times to times we update the Twitter URL length. self.publisher.update_twitter_link_length()
from mtt import config from mtt.credentials import check_credentials, setup_credentials from mtt.mastodon_to_twitter import TwitterPublisher from mtt.twitter_to_mastodon import MastodonPublisher from mtt.utils import lgt # # First step: check credentials # if not check_credentials(): setup_credentials() lgt('Everything looks good; starting…') # # Read in credentials # with config.FILES['credentials_twitter'].open('r') as secret_file: TWITTER_CONSUMER_KEY = secret_file.readline().rstrip() TWITTER_CONSUMER_SECRET = secret_file.readline().rstrip() TWITTER_ACCESS_KEY = secret_file.readline().rstrip() TWITTER_ACCESS_SECRET = secret_file.readline().rstrip() with config.FILES['credentials_mastodon_server'].open('r') as secret_file: MASTODON_BASE_URL = secret_file.readline().rstrip()