diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index b8c6716..4dbdfbb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ worker_*.lock .idea/moa.iml Pipfile Pipefile.lock +/.direnv diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..30e36e9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.formatting.provider": "yapf" +} diff --git a/app.py b/app.py index b1bcb8d..4a445f4 100644 --- a/app.py +++ b/app.py @@ -3,12 +3,12 @@ import os import random from datetime import datetime, timedelta from urllib.error import URLError +import sys import pandas as pd import pygal import twitter from authlib.common.errors import AuthlibBaseError -from authlib.integrations._client import MissingRequestTokenError from flask import Flask, flash, g, redirect, render_template, request, session, url_for from flask_migrate import Migrate from authlib.integrations.flask_client import OAuth @@ -30,12 +30,17 @@ from moa.forms import MastodonIDForm, SettingsForm from moa.helpers import blacklisted, email_bridge_details, send_blacklisted_email, timespan, FORMAT from moa.models import Bridge, MastodonHost, TSettings, WorkerStat, metadata, BridgeStat, BridgeMetadata + +class MissingRequestTokenError(Exception): + pass + + app = Flask(__name__) formatter = logging.Formatter(FORMAT) # initialize the log handler -logHandler = logging.FileHandler('logs/app.log') +logHandler = logging.StreamHandler(sys.stderr) logHandler.setFormatter(formatter) # set the app logger level @@ -61,14 +66,15 @@ if app.config['SENTRY_DSN']: from sentry_sdk.integrations.flask import FlaskIntegration sentry_logging = LoggingIntegration( - level=logging.INFO, # Capture info and above as breadcrumbs - event_level=logging.FATAL # Only send fatal errors as events + level=logging.INFO, # Capture info and above as breadcrumbs + event_level=logging.FATAL # Only send fatal errors as events ) - sentry_sdk.init( - dsn=app.config['SENTRY_DSN'], - integrations=[FlaskIntegration(), sentry_logging, SqlalchemyIntegration()] - ) + sentry_sdk.init(dsn=app.config['SENTRY_DSN'], + integrations=[ + FlaskIntegration(), sentry_logging, + SqlalchemyIntegration() + ]) db = SQLAlchemy(metadata=metadata) migrate = Migrate(app, db) @@ -78,17 +84,17 @@ oauth = OAuth(app) if app.config.get('TWITTER_CONSUMER_KEY', None): oauth.register( - name='twitter', - client_id=app.config['TWITTER_CONSUMER_KEY'], - client_secret=app.config['TWITTER_CONSUMER_SECRET'], - request_token_url='https://api.twitter.com/oauth/request_token', - request_token_params=None, - access_token_url='https://api.twitter.com/oauth/access_token', - access_token_params=None, - authorize_url='https://api.twitter.com/oauth/authenticate', - authorize_params=None, - api_base_url='https://api.twitter.com/1.1/', - client_kwargs=None, + name='twitter', + client_id=app.config['TWITTER_CONSUMER_KEY'], + client_secret=app.config['TWITTER_CONSUMER_SECRET'], + request_token_url='https://api.twitter.com/oauth/request_token', + request_token_params=None, + access_token_url='https://api.twitter.com/oauth/access_token', + access_token_params=None, + authorize_url='https://api.twitter.com/oauth/authenticate', + authorize_params=None, + api_base_url='https://api.twitter.com/1.1/', + client_kwargs=None, ) # mastodon_scopes = ["write:statuses", "write:media", "read:accounts", "read:statuses"] @@ -118,28 +124,32 @@ def index(): form = SettingsForm(obj=settings) if 'bridge_id' in session: - bridge = db.session.query(Bridge).filter_by(id=session['bridge_id']).first() + bridge = db.session.query(Bridge).filter_by( + id=session['bridge_id']).first() if bridge: g.bridge = bridge settings = bridge.t_settings - app.logger.debug(f"Existing settings found: {enabled} {settings.__dict__}") + app.logger.debug( + f"Existing settings found: {enabled} {settings.__dict__}") form = SettingsForm(obj=settings) if not bridge.mastodon_access_code or not bridge.twitter_oauth_token: form.remove_masto_and_twitter_fields() - return render_template('index.html.j2', - form=form, - mform=mform, - ) + return render_template( + 'index.html.j2', + form=form, + mform=mform, + ) @app.route('/options', methods=["POST"]) def options(): if 'bridge_id' in session: - bridge = db.session.query(Bridge).filter_by(id=session['bridge_id']).first() + bridge = db.session.query(Bridge).filter_by( + id=session['bridge_id']).first() else: flash('ERROR: Please log in to an account') return redirect(url_for('index')) @@ -179,11 +189,11 @@ def catch_up_twitter(bridge): if bridge.twitter_last_id == 0 and bridge.twitter_oauth_token: # get twitter ID twitter_api = twitter.Api( - consumer_key=app.config['TWITTER_CONSUMER_KEY'], - consumer_secret=app.config['TWITTER_CONSUMER_SECRET'], - access_token_key=bridge.twitter_oauth_token, - access_token_secret=bridge.twitter_oauth_secret, - tweet_mode='extended' # Allow tweets longer than 140 raw characters + consumer_key=app.config['TWITTER_CONSUMER_KEY'], + consumer_secret=app.config['TWITTER_CONSUMER_SECRET'], + access_token_key=bridge.twitter_oauth_token, + access_token_secret=bridge.twitter_oauth_secret, + tweet_mode='extended' # Allow tweets longer than 140 raw characters ) try: tl = twitter_api.GetUserTimeline() @@ -192,7 +202,8 @@ def catch_up_twitter(bridge): else: if len(tl) > 0: bridge.twitter_last_id = tl[0].id - d = datetime.strptime(tl[0].created_at, '%a %b %d %H:%M:%S %z %Y') + d = datetime.strptime(tl[0].created_at, + '%a %b %d %H:%M:%S %z %Y') bridge.md.last_tweet = d else: bridge.twitter_last_id = 0 @@ -227,13 +238,15 @@ def catch_up_mastodon(bridge): @app.route('/delete', methods=["POST"]) def delete(): if 'bridge_id' in session: - bridge = db.session.query(Bridge).filter_by(id=session['bridge_id']).first() + bridge = db.session.query(Bridge).filter_by( + id=session['bridge_id']).first() if bridge: app.logger.info(f"Deleting settings for Bridge {bridge.id}") settings = bridge.t_settings md = bridge.md - db.session.query(BridgeStat).filter_by(bridge_id=bridge.id).delete() + db.session.query(BridgeStat).filter_by( + bridge_id=bridge.id).delete() db.session.delete(bridge) db.session.delete(settings) db.session.delete(md) @@ -248,11 +261,9 @@ def delete(): @app.route('/twitter_login') def twitter_login(): - callback_url = url_for( - 'twitter_oauthorized', - _external=True, - next=request.args.get('next') - ) + callback_url = url_for('twitter_oauthorized', + _external=True, + next=request.args.get('next')) app.logger.debug(callback_url) @@ -275,9 +286,12 @@ def twitter_oauthorized(): return redirect(url_for('index')) if resp is None: - flash('ERROR: You denied the request to sign in or have cookies disabled.') + flash( + 'ERROR: You denied the request to sign in or have cookies disabled.' + ) - elif blacklisted(resp['screen_name'], app.config.get('TWITTER_BLACKLIST', [])): + elif blacklisted(resp['screen_name'], + app.config.get('TWITTER_BLACKLIST', [])): flash('ERROR: Access Denied.') send_blacklisted_email(app, resp['screen_name']) @@ -308,18 +322,18 @@ def twitter_oauthorized(): def get_or_create_host(hostname): - mastodonhost = db.session.query(MastodonHost).filter_by(hostname=hostname).first() + mastodonhost = db.session.query(MastodonHost).filter_by( + hostname=hostname).first() if not mastodonhost: try: client_id, client_secret = Mastodon.create_app( - "Moa", - scopes=mastodon_scopes, - api_base_url=f"https://{hostname}", - website="https://moa.party/", - redirect_uris=url_for("mastodon_oauthorized", _external=True) - ) + "Moa", + scopes=mastodon_scopes, + api_base_url=f"https://{hostname}", + website="https://moa.party/", + redirect_uris=url_for("mastodon_oauthorized", _external=True)) app.logger.info(f"New host created for {hostname}") @@ -346,13 +360,11 @@ def mastodon_api(hostname, access_code=None): mastodonhost = get_or_create_host(hostname) if mastodonhost: - api = Mastodon( - client_id=mastodonhost.client_id, - client_secret=mastodonhost.client_secret, - api_base_url=f"https://{mastodonhost.hostname}", - access_token=access_code, - debug_requests=False - ) + api = Mastodon(client_id=mastodonhost.client_id, + client_secret=mastodonhost.client_secret, + api_base_url=f"https://{mastodonhost.hostname}", + access_token=access_code, + debug_requests=False) return api return None @@ -403,6 +415,10 @@ def mastodon_login(): if host in app.config.get('MASTODON_BLACKLIST', []): flash('Access Denied') return redirect(url_for('index')) + allow_list = app.config.get('MASTODON_ALLOWLIST', []) + if len(allow_list) != 0 and host not in allow_list: + flash('Access Denied') + return redirect(url_for('index')) session['mastodon_host'] = host @@ -410,12 +426,11 @@ def mastodon_login(): if api: return redirect( - api.auth_request_url( - scopes=mastodon_scopes, - force_login=True, - redirect_uris=url_for("mastodon_oauthorized", _external=True) - ) - ) + api.auth_request_url(scopes=mastodon_scopes, + force_login=True, + redirect_uris=url_for( + "mastodon_oauthorized", + _external=True))) else: flash(f"There was a problem connecting to the mastodon server.") else: @@ -437,7 +452,9 @@ def mastodon_oauthorized(): app.logger.info(f"Authorization code {authorization_code} for {host}") if not host: - flash('There was an error. Please ensure you allow this site to use cookies.') + flash( + 'There was an error. Please ensure you allow this site to use cookies.' + ) return redirect(url_for('index')) session.pop('mastodon_host', None) @@ -447,19 +464,23 @@ def mastodon_oauthorized(): local_scopes = mastodon_scopes try: - access_code = api.log_in( - code=authorization_code, - scopes=local_scopes, - redirect_uri=url_for("mastodon_oauthorized", _external=True) - ) + access_code = api.log_in(code=authorization_code, + scopes=local_scopes, + redirect_uri=url_for( + "mastodon_oauthorized", + _external=True)) except MastodonAPIError as e: # Possibly a scopes problem? - flash(f"There was a problem connecting to the mastodon server. The error was {e}") + flash( + f"There was a problem connecting to the mastodon server. The error was {e}" + ) return redirect(url_for('index')) except MastodonIllegalArgumentError as e: - flash(f"There was a problem connecting to the mastodon server. The error was {e}") + flash( + f"There was a problem connecting to the mastodon server. The error was {e}" + ) return redirect(url_for('index')) # app.logger.info(f"Access code {access_code}") @@ -470,13 +491,17 @@ def mastodon_oauthorized(): creds = api.account_verify_credentials() except (MastodonUnauthorizedError, MastodonAPIError) as e: - flash(f"There was a problem connecting to the mastodon server. The error was {e}") + flash( + f"There was a problem connecting to the mastodon server. The error was {e}" + ) return redirect(url_for('index')) username = creds["username"] account_id = creds["id"] - bridge = db.session.query(Bridge).filter_by(mastodon_account_id=account_id, mastodon_host_id=masto_host.id).first() + bridge = db.session.query(Bridge).filter_by( + mastodon_account_id=account_id, + mastodon_host_id=masto_host.id).first() if bridge: session['bridge_id'] = bridge.id @@ -522,7 +547,9 @@ def instagram_activate(): # app.logger.info(redirect_uri) scope = ["basic"] - api = InstagramAPI(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri) + api = InstagramAPI(client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri) try: redirect_uri = api.get_authorize_login_url(scope=scope) @@ -542,7 +569,9 @@ def instagram_oauthorized(): client_id = app.config['INSTAGRAM_CLIENT_ID'] client_secret = app.config['INSTAGRAM_SECRET'] redirect_uri = url_for('instagram_oauthorized', _external=True) - api = InstagramAPI(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri) + api = InstagramAPI(client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri) try: access_token = api.exchange_code_for_access_token(code) @@ -567,15 +596,18 @@ def instagram_oauthorized(): bridge.instagram_account_id = data['id'] bridge.instagram_handle = data['username'] - user_api = InstagramAPI(access_token=bridge.instagram_access_code, client_secret=client_secret) + user_api = InstagramAPI(access_token=bridge.instagram_access_code, + client_secret=client_secret) try: - latest_media, _ = user_api.user_recent_media(user_id=bridge.instagram_account_id, count=1) + latest_media, _ = user_api.user_recent_media( + user_id=bridge.instagram_account_id, count=1) except Exception: latest_media = [] if len(latest_media) > 0: - bridge.instagram_last_id = datetime_to_timestamp(latest_media[0].created_time) + bridge.instagram_last_id = datetime_to_timestamp( + latest_media[0].created_time) else: bridge.instagram_last_id = 0 @@ -597,8 +629,7 @@ def logout(): def stats(): hours = request.args.get('hours', 24) - return render_template('stats.html.j2', - hours=hours) + return render_template('stats.html.j2', hours=hours) @app.route('/deactivate_account') @@ -636,9 +667,10 @@ def time_graph(): hours = int(request.args.get('hours', 24)) since = datetime.now() - timedelta(hours=hours) - stats_query = db.session.query(WorkerStat).filter(WorkerStat.created > since).with_entities(WorkerStat.created, - WorkerStat.time, - WorkerStat.worker) + stats_query = db.session.query(WorkerStat).filter( + WorkerStat.created > since).with_entities(WorkerStat.created, + WorkerStat.time, + WorkerStat.worker) df = pd.read_sql(stats_query.statement, stats_query.session.bind) @@ -684,10 +716,11 @@ def count_graph(): hours = int(request.args.get('hours', 24)) since = datetime.now() - timedelta(hours=hours) - stats_query = db.session.query(WorkerStat).filter(WorkerStat.created > since).with_entities(WorkerStat.created, - WorkerStat.toots, - WorkerStat.tweets, - WorkerStat.instas) + stats_query = db.session.query(WorkerStat).filter( + WorkerStat.created > since).with_entities(WorkerStat.created, + WorkerStat.toots, + WorkerStat.tweets, + WorkerStat.instas) df = pd.read_sql(stats_query.statement, stats_query.session.bind) df.set_index(['created'], inplace=True) @@ -702,9 +735,10 @@ def count_graph(): tweets = r['tweets'].tolist() instas = r['instas'].tolist() - chart = pygal.StackedBar(title=f"# of Incoming Messages ({timespan(hours)})\n{total} total", - human_readable=True, - legend_at_bottom=True) + chart = pygal.StackedBar( + title=f"# of Incoming Messages ({timespan(hours)})\n{total} total", + human_readable=True, + legend_at_bottom=True) chart.add('Toots', toots) chart.add('Tweets', tweets) chart.add('Instas', instas) @@ -717,10 +751,11 @@ def percent_graph(): hours = int(request.args.get('hours', 24)) since = datetime.now() - timedelta(hours=hours) - stats_query = db.session.query(WorkerStat).filter(WorkerStat.created > since).with_entities(WorkerStat.created, - WorkerStat.toots, - WorkerStat.tweets, - WorkerStat.instas) + stats_query = db.session.query(WorkerStat).filter( + WorkerStat.created > since).with_entities(WorkerStat.created, + WorkerStat.toots, + WorkerStat.tweets, + WorkerStat.instas) df = pd.read_sql(stats_query.statement, stats_query.session.bind) df.set_index(['created'], inplace=True) @@ -738,9 +773,10 @@ def percent_graph(): tweets_p = r['tweets_p'].tolist() instas_p = r['instas_p'].tolist() - chart = pygal.StackedBar(title=f"Ratio of Incoming Messages ({timespan(hours)})", - human_readable=True, - legend_at_bottom=True) + chart = pygal.StackedBar( + title=f"Ratio of Incoming Messages ({timespan(hours)})", + human_readable=True, + legend_at_bottom=True) chart.add('Toots', toots_p) chart.add('Tweets', tweets_p) chart.add('Instas', instas_p) @@ -753,7 +789,8 @@ def user_graph(): hours = int(request.args.get('hours', 24)) since = datetime.now() - timedelta(hours=hours) - stats_query = db.session.query(Bridge).filter(Bridge.created > since).filter(Bridge.enabled == 1).with_entities( + stats_query = db.session.query(Bridge).filter( + Bridge.created > since).filter(Bridge.enabled == 1).with_entities( Bridge.created) base_count_query = db.session.query(func.count(Bridge.id)).scalar() @@ -795,4 +832,4 @@ def page_not_found(e): if __name__ == '__main__': - app.run() + app.run(host="::1") diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ff25756 --- /dev/null +++ b/flake.nix @@ -0,0 +1,53 @@ +{ + inputs = { + nixpkgs.url = github:NixOS/nixpkgs; + flake-utils.url = github:numtide/flake-utils; + nix-packages.url = git+https://git.chir.rs/darkkirb/nix-packages; + nix-packages.inputs = { + nixpkgs.follows = "nixpkgs"; + flake-utils.follows = "flake-utils"; + }; + }; + outputs = { + self, + nixpkgs, + flake-utils, + nix-packages, + }: + flake-utils.lib.eachDefaultSystem (system: let + pkgs = import nixpkgs {inherit system;}; + nix-pkgs = import nix-packages {inherit pkgs;}; + in { + formatter = pkgs.alejandra; + devShells.default = pkgs.mkShell { + MOA_CONFIG = "DevelopmentConfig"; + nativeBuildInputs = [ + (pkgs.python3.withPackages (ps: + with ps; [ + certifi + flask + flask_sqlalchemy + flask_mail + flask_migrate + flask_wtf + mastodon-py + pandas + psutil + pygal + python-twitter + pymysql + sentry-sdk + authlib + cairosvg + werkzeug + wheel + setuptools + nix-pkgs.python-instagram + psycopg2 + ])) + pkgs.yapf + pkgs.sqlite + ]; + }; + }); +} diff --git a/migrations/alembic.ini b/migrations/alembic.ini index f8ed480..5301449 100644 --- a/migrations/alembic.ini +++ b/migrations/alembic.ini @@ -7,6 +7,7 @@ # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false +script_location = . # Logging configuration diff --git a/migrations/env.py b/migrations/env.py index 690d48b..c996c74 100755 --- a/migrations/env.py +++ b/migrations/env.py @@ -18,6 +18,7 @@ logger = logging.getLogger('alembic.env') # from myapp import mymodel # target_metadata = mymodel.Base.metadata from flask import current_app + config.set_main_option('sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI')) target_metadata = current_app.extensions['migrate'].db.metadata @@ -82,6 +83,7 @@ def run_migrations_online(): finally: connection.close() + if context.is_offline_mode(): run_migrations_offline() else: diff --git a/migrations/versions/b0780999e063_allow_deny_list.py b/migrations/versions/b0780999e063_allow_deny_list.py new file mode 100644 index 0000000..5344b25 --- /dev/null +++ b/migrations/versions/b0780999e063_allow_deny_list.py @@ -0,0 +1,38 @@ +"""Allow/Deny list + +Revision ID: b0780999e063 +Revises: 3ac471544742 +Create Date: 2022-11-03 07:53:00 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'b0780999e063' +down_revision = '3ac471544742' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('block_allow_list', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pattern', sa.String(length=100), + nullable=False), + sa.Column('allow', sa.Boolean(), nullable=False), + sa.Column('settings_id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4', + mysql_collate='utf8mb4_general_ci') + op.create_foreign_key(None, 'block_allow_list', 'settings', + ['settings_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('block_allow_list') + # ### end Alembic commands ### diff --git a/moa/models.py b/moa/models.py index e57604b..050e87c 100644 --- a/moa/models.py +++ b/moa/models.py @@ -51,22 +51,33 @@ CON_XP_ONLYIF_TAGS = ['moa', 'xp'] CON_XP_UNLESS = 'unless' CON_XP_UNLESS_TAGS = ['nomoa', 'noxp'] + class TSettings(Base): __tablename__ = 'settings' - __table_args__ = {'mysql_charset': 'utf8mb4', 'mysql_collate': 'utf8mb4_general_ci'} + __table_args__ = { + 'mysql_charset': 'utf8mb4', + 'mysql_collate': 'utf8mb4_general_ci' + } id = Column(Integer, primary_key=True) bridge = relationship('Bridge', backref='t_settings', lazy='dynamic') - conditional_posting = Column(String(10), nullable=False, server_default=CON_XP_DISABLED, default=CON_XP_DISABLED) + allow_deny_list = relationship('AllowDenyList', lazy='dynamic') + conditional_posting = Column(String(10), + nullable=False, + server_default=CON_XP_DISABLED, + default=CON_XP_DISABLED) # Masto -> Twitter - post_to_twitter = Column(Boolean, nullable=False, default=True) # This means post public toots + post_to_twitter = Column(Boolean, nullable=False, + default=True) # This means post public toots post_private_to_twitter = Column(Boolean, nullable=False, default=False) post_unlisted_to_twitter = Column(Boolean, nullable=False, default=False) split_twitter_messages = Column(Boolean, nullable=False, default=True) post_boosts_to_twitter = Column(Boolean, nullable=False, default=True) post_sensitive_behind_link = Column(Boolean, nullable=False, default=False) - sensitive_link_text = Column(String(100), nullable=False, default='(NSFW Image)') + sensitive_link_text = Column(String(100), + nullable=False, + default='(NSFW Image)') remove_cw = Column(Boolean, nullable=False, default=False) # Twitter -> Masto @@ -75,7 +86,9 @@ class TSettings(Base): post_quotes_to_mastodon = Column(Boolean, nullable=False, default=True) toot_visibility = Column(String(40), nullable=False, default='public') tweets_behind_cw = Column(Boolean, nullable=False, default=False) - tweet_cw_text = Column(String(100), nullable=False, default="From birdsite") + tweet_cw_text = Column(String(100), + nullable=False, + default="From birdsite") instagram_post_to_twitter = Column(Boolean, nullable=False, default=False) instagram_post_to_mastodon = Column(Boolean, nullable=False, default=False) @@ -116,6 +129,19 @@ class TSettings(Base): self.post_rts_to_mastodon +class AllowDenyList(Base): + __tablename__ = "block_allow_list" + __table_args__ = { + 'mysql_charset': 'utf8mb4', + 'mysql_collate': 'utf8mb4_general_ci' + } + + id = Column(Integer, primary_key=True) + pattern = Column(String(100), nullable=False) + allow = Column(Boolean, nullable=False) + settings_id = Column(Integer, ForeignKey('settings.id'), nullable=False) + + class Bridge(Base): __tablename__ = 'bridge' @@ -139,7 +165,9 @@ class Bridge(Base): instagram_handle = Column(String(30)) t_settings_id = Column(Integer, ForeignKey('settings.id'), nullable=True) - metadata_id = Column(Integer, ForeignKey('bridgemetadata.id'), nullable=True) + metadata_id = Column(Integer, + ForeignKey('bridgemetadata.id'), + nullable=True) created = Column(DateTime, default=datetime.utcnow) updated = Column(DateTime) @@ -258,7 +286,7 @@ if __name__ == '__main__': import pymysql engine = create_engine(config.SQLALCHEMY_DATABASE_URI) - metadata = MetaData(engine, reflect=True) + metadata = MetaData(engine) print("Creating Tables") Base.metadata.create_all(engine) diff --git a/moa/toot.py b/moa/toot.py index 08aa61d..afce494 100644 --- a/moa/toot.py +++ b/moa/toot.py @@ -8,11 +8,11 @@ from moa.message import Message from moa.models import CON_XP_ONLYIF, CON_XP_ONLYIF_TAGS, CON_XP_UNLESS, CON_XP_UNLESS_TAGS from moa.tweet import HOUR_CUTOFF -MY_TLDS = [ - "shop" -] +MY_TLDS = ["shop"] -URL_REGEXP = re.compile(r"([--:\w?@%&+~#=]*\.[a-z]{2,4}\/{0,2})((?:[?&](?:\w+)=(?:\w+))+|[--:\w?@%&+~#=]+)?", re.U | re.I) +URL_REGEXP = re.compile( + r"([--:\w?@%&+~#=]*\.[a-z]{2,4}\/{0,2})((?:[?&](?:\w+)=(?:\w+))+|[--:\w?@%&+~#=]+)?", + re.U | re.I) logger = logging.getLogger('worker') @@ -69,7 +69,8 @@ class Toot(Message): @property def is_self_reply(self): - return self.is_reply and self.data['in_reply_to_account_id'] == self.data['account']['id'] + return self.is_reply and self.data[ + 'in_reply_to_account_id'] == self.data['account']['id'] @property def is_boost(self): @@ -92,7 +93,7 @@ class Toot(Message): @property def media_attachments(self): if self.is_boost: - return self.data['reblog']['media_attachments'] + return [] else: return self.data['media_attachments'] @@ -111,7 +112,6 @@ class Toot(Message): @property def should_skip(self): - if self.too_old: logger.info(f'Skipping because >= {HOUR_CUTOFF} hours old.') return True @@ -125,6 +125,30 @@ class Toot(Message): logger.info(f'Skipping reply.') return True + found = True + for entry in self.settings.allow_deny_list: + hasEntry = True + toot_tags = {x.name for x in self.data['tags']} + if entry.allow: + if entry.pattern not in self.content: + found = False + break + if entry.pattern.startswith( + "#") and entry.pattern[1:] not in toot_tags: + found = False + break + else: + if entry.pattern in self.content: + found = False + break + if entry.pattern.startswith( + "#") and entry.pattern[1:] in toot_tags: + found = False + break + + if not found: + return True + if self.visibility == 'private' and not self.settings.post_private_to_twitter: logger.info(f'Skipping: Not Posting Private toots.') return True @@ -202,12 +226,14 @@ class Toot(Message): status_length += self.url_length if self.is_sensitive and self.settings.post_sensitive_behind_link: - status_length += len(f"\n{self.settings.sensitive_link_text}\n{self.url}") + status_length += len( + f"\n{self.settings.sensitive_link_text}\n{self.url}") return status_length def sanitize_twitter_handles(self): - self.content = re.sub(r'@?(\w{1,15})@twitter.com', '\g<1>', self.content) + self.content = re.sub(r'@?(\w{1,15})@twitter.com', '\g<1>', + self.content) # find possible twitter handles so we can get their ranges tm = list(re.finditer(r'@(\w{1,15})', self.content)) @@ -250,50 +276,55 @@ class Toot(Message): @property def clean_content(self): - media_regexp = re.compile(re.escape(self.instance_url) + "\/media\/[\w-]+\s?") + media_regexp = re.compile( + re.escape(self.instance_url) + "\/media\/[\w-]+\s?") if not self.content: self.content = self.raw_content # We trust mastodon to return valid HTML - self.content = re.sub(r']*href="([^"]+)">[^<]*', '\g<1>', self.content) + self.content = re.sub(r']*href="([^"]+)">([^<]*)', + '\g<1> (\g<2>)', self.content) # We replace html br with new lines - self.content = "\n".join(re.compile(r'
', re.IGNORECASE).split(self.content)) + self.content = "\n".join( + re.compile(r'
', re.IGNORECASE).split(self.content)) # We must also replace new paragraphs with double line skips - self.content = "\n\n".join(re.compile(r'

', re.IGNORECASE).split(self.content)) + self.content = "\n\n".join( + re.compile(r'

', re.IGNORECASE).split(self.content)) # Then we can delete the other html contents and unescape the string - self.content = html.unescape(str(re.compile(r'<.*?>').sub("", self.content).strip())) + self.content = html.unescape( + str(re.compile(r'<.*?>').sub("", self.content).strip())) # Trim out media URLs self.content = re.sub(media_regexp, "", self.content) # fix up masto mentions for mention in self.mentions: - self.content = re.sub(f'@({mention[0]})(?!@)', f"{mention[1]}", self.content) + self.content = re.sub(f'@({mention[0]})(?!@)', f"{mention[1]}", + self.content) if self.config.SANITIZE_TWITTER_HANDLES: self.sanitize_twitter_handles() else: - self.content = re.sub(r'@(\w{1,15})@twitter.com', '@\g<1>', self.content) + self.content = re.sub(r'@(\w{1,15})@twitter.com', '@\g<1>', + self.content) self.content = self.content.strip() if self.spoiler_text and not self.settings.remove_cw: self.content = f"CW: {self.spoiler_text}\n\n{self.content}" - if self.is_sensitive and self.settings.post_sensitive_behind_link and len(self.media_attachments) > 0: + if self.is_sensitive and self.settings.post_sensitive_behind_link and len( + self.media_attachments) > 0: self.content = f"{self.content}\n{self.settings.sensitive_link_text}\n{self.url}" if self.is_boost: - if len(self.content) > 0: - self.content = f"RT {self.boost_author}\n{self.content}\n{self.url}" - else: - self.content = f"RT {self.boost_author}\n{self.url}\n" + self.content = f"RT {self.boost_author}\n{self.url}\n" # logger.debug(self.content) @@ -318,7 +349,9 @@ class Toot(Message): words = self.clean_content.split(" ") if self.settings.split_twitter_messages: - logger.info(f'Toot bigger than {max_length} characters, need to split...') + logger.info( + f'Toot bigger than {max_length} characters, need to split...' + ) for next_word in words: @@ -348,7 +381,8 @@ class Toot(Message): self.message_parts.append(current_part.strip()) for i, msg in enumerate(self.message_parts): - self.message_parts[i] = msg.replace('XXXXX', f"({i+1}/{len(self.message_parts)})") + self.message_parts[i] = msg.replace( + 'XXXXX', f"({i+1}/{len(self.message_parts)})") logger.debug(self.message_parts[i]) else: logger.info('Truncating toot') diff --git a/moa/tweet.py b/moa/tweet.py index b156967..c5c556a 100644 --- a/moa/tweet.py +++ b/moa/tweet.py @@ -20,6 +20,7 @@ HANDLE_SUFFIX = '' class Tweet(Message): + def __init__(self, settings, data, api): super().__init__(settings, data) @@ -38,7 +39,8 @@ class Tweet(Message): @property def created_at(self): - return datetime.strptime(self.data.created_at, '%a %b %d %H:%M:%S %z %Y') + return datetime.strptime(self.data.created_at, + '%a %b %d %H:%M:%S %z %Y') @property def too_old(self) -> bool: @@ -48,6 +50,8 @@ class Tweet(Message): @property def media(self): + if self.is_retweet: + return [] if not self.__fetched_attachments: @@ -67,13 +71,11 @@ class Tweet(Message): target_id = self.data.id try: - fetched_tweet = self.api.GetStatus( - status_id=target_id, - trim_user=True, - include_my_retweet=False, - include_entities=True, - include_ext_alt_text=True - ) + fetched_tweet = self.api.GetStatus(status_id=target_id, + trim_user=True, + include_my_retweet=False, + include_entities=True, + include_ext_alt_text=True) self.__fetched_attachments = fetched_tweet.media except (TwitterError, ConnectionError) as e: @@ -122,7 +124,7 @@ class Tweet(Message): logger.info(f'Skipping because {local_tags} found') return True - if not self.settings.post_to_mastodon: + elif not self.settings.post_to_mastodon: logger.info(f'Skipping regular tweets.') return True @@ -130,7 +132,7 @@ class Tweet(Message): @property def url(self): - base = "https://twitter.com" + base = "https://twitter.catcatnya.com" user = self.data.user.screen_name status = self.data.id @@ -185,9 +187,11 @@ class Tweet(Message): def mentions(self): if self.is_retweet: - m = [(u.screen_name, u._json['indices']) for u in self.data.retweeted_status.user_mentions] + m = [(u.screen_name, u._json['indices']) + for u in self.data.retweeted_status.user_mentions] else: - m = [(u.screen_name, u._json['indices']) for u in self.data.user_mentions] + m = [(u.screen_name, u._json['indices']) + for u in self.data.user_mentions] return m @@ -195,7 +199,8 @@ class Tweet(Message): def quoted_mentions(self): if self.data.quoted_status: - m = [(u.screen_name, u._json['indices']) for u in self.data.quoted_status.user_mentions] + m = [(u.screen_name, u._json['indices']) + for u in self.data.quoted_status.user_mentions] return m @@ -246,12 +251,14 @@ class Tweet(Message): content = re.sub(r'https://twitter.com/.*$', '', content) quoted_text = self.data.quoted_status.full_text - quoted_text = self.expand_handles(quoted_text, self.quoted_mentions) + quoted_text = self.expand_handles(quoted_text, + self.quoted_mentions) quoted_text = html.unescape(quoted_text) for url in self.data.quoted_status.urls: # Unshorten URLs - quoted_text = re.sub(url.url, url.expanded_url, quoted_text) + quoted_text = re.sub(url.url, url.expanded_url, + quoted_text) else: content = self.data.full_text @@ -271,6 +278,8 @@ class Tweet(Message): content = re.sub(url.url, url.expanded_url, content) if self.is_retweet: + self.__content = f"RT @{self.data.retweeted_status.user.screen_name}{HANDLE_SUFFIX}\n{self.url}" + return self.__content if len(content) > 0: content = f"RT @{self.data.retweeted_status.user.screen_name}{HANDLE_SUFFIX}\n{content}" else: @@ -310,6 +319,8 @@ class Tweet(Message): @property def media_attachments(self): + if self.is_retweet: + return [] attachments = [] @@ -366,13 +377,16 @@ class Tweet(Message): except (ConnectionError, NewConnectionError) as e: logger.error(f"{e}") attachment_url = None - raise MoaMediaUploadException("Connection Error fetching attachments") + raise MoaMediaUploadException( + "Connection Error fetching attachments") else: attachment_url = attachment.media_url if attachment_url: - attachments.append({'url': attachment_url, - 'description': attachment.ext_alt_text}) + attachments.append({ + 'url': attachment_url, + 'description': attachment.ext_alt_text + }) return attachments diff --git a/moa/worker.py b/moa/worker.py index 733cb48..9c89934 100644 --- a/moa/worker.py +++ b/moa/worker.py @@ -45,15 +45,22 @@ if c.SENTRY_DSN: from sentry_sdk.integrations.logging import LoggingIntegration sentry_logging = LoggingIntegration( - level=logging.INFO, # Capture info and above as breadcrumbs - event_level=logging.FATAL # Only send fatal errors as events + level=logging.INFO, # Capture info and above as breadcrumbs + event_level=logging.FATAL # Only send fatal errors as events ) - sentry_sdk.init(dsn=c.SENTRY_DSN, integrations=[sentry_logging, - SqlalchemyIntegration(), - FlaskIntegration()]) + sentry_sdk.init(dsn=c.SENTRY_DSN, + integrations=[ + sentry_logging, + SqlalchemyIntegration(), + FlaskIntegration() + ]) parser = argparse.ArgumentParser(description='Moa Worker') -parser.add_argument('--worker', dest='worker', type=int, required=False, default=1) +parser.add_argument('--worker', + dest='worker', + type=int, + required=False, + default=1) args = parser.parse_args() worker_stat = WorkerStat(worker=args.worker) @@ -87,7 +94,7 @@ except exc.SQLAlchemyError as e: session = Session(engine) -lockfile = Path(f'worker_{args.worker}.lock') +lockfile = Path(f'/tmp/moa_worker_{args.worker}.lock') def check_worker_stop(): @@ -131,11 +138,13 @@ with lockfile.open('wt') as f: if not c.SEND: l.warning("SENDING IS NOT ENABLED") -bridges = session.query(Bridge).filter_by(enabled=True).filter(BridgeMetadata.worker_id == args.worker).filter(BridgeMetadata.id == Bridge.metadata_id) +bridges = session.query(Bridge).filter_by(enabled=True).filter( + BridgeMetadata.worker_id == args.worker).filter( + BridgeMetadata.id == Bridge.metadata_id) l.info(f"Working on {bridges.count()} bridges") -if 'sqlite' not in c.SQLALCHEMY_DATABASE_URI and not c.DEVELOPMENT: +if 'sqlite' not in c.SQLALCHEMY_DATABASE_URI and 'postgresql' not in c.SQLALCHEMY_DATABASE_URI and not c.DEVELOPMENT: bridges = bridges.order_by(func.rand()) bridge_count = 0 @@ -173,32 +182,38 @@ for bridge in bridges: mastodon_last_id = bridge.mastodon_last_id mastodonhost = bridge.mastodon_host - if mastodonhost.defer_until and mastodonhost.defer_until > datetime.now(): + if mastodonhost.defer_until and mastodonhost.defer_until > datetime.now( + ): l.warning(f"Deferring connections to {mastodonhost.hostname}") continue - mast_api = Mastodon( - client_id=mastodonhost.client_id, - client_secret=mastodonhost.client_secret, - api_base_url=f"https://{mastodonhost.hostname}", - access_token=bridge.mastodon_access_code, - debug_requests=False, - request_timeout=15, - ratelimit_method='throw' - ) + mast_api = Mastodon(client_id=mastodonhost.client_id, + client_secret=mastodonhost.client_secret, + api_base_url=f"https://{mastodonhost.hostname}", + access_token=bridge.mastodon_access_code, + debug_requests=False, + request_timeout=15, + ratelimit_method='throw') try: new_toots = mast_api.account_statuses( - bridge.mastodon_account_id, - since_id=bridge.mastodon_last_id - ) + bridge.mastodon_account_id, since_id=bridge.mastodon_last_id) except (MastodonAPIError, MastodonNetworkError) as e: msg = f"{bridge.mastodon_user}@{mastodonhost.hostname} MastodonAPIError: {e}" l.error(msg) - if any(x in repr(e) for x in ['revoked', 'invalid', 'not found', 'Forbidden', 'Unauthorized', 'Bad Request', - 'Name or service not known',]): - l.warning(f"Disabling bridge for user {bridge.mastodon_user}@{mastodonhost.hostname}") + if any(x in repr(e) for x in [ + 'revoked', + 'invalid', + 'not found', + 'Forbidden', + 'Unauthorized', + 'Bad Request', + 'Name or service not known', + ]): + l.warning( + f"Disabling bridge for user {bridge.mastodon_user}@{mastodonhost.hostname}" + ) bridge.mastodon_access_code = None bridge.enabled = False else: @@ -273,9 +288,11 @@ for bridge in bridges: except MastodonRatelimitError as e: l.error(f"{bridge.mastodon_user}@{mastodonhost.hostname}: {e}") - if len(new_toots) > c.MAX_MESSAGES_PER_RUN: - l.error(f"{bridge.mastodon_user}@{mastodonhost.hostname}: Limiting to {c.MAX_MESSAGES_PER_RUN} messages") - new_toots = new_toots[-c.MAX_MESSAGES_PER_RUN:] + #if len(new_toots) > c.MAX_MESSAGES_PER_RUN: + # l.error( + # f"{bridge.mastodon_user}@{mastodonhost.hostname}: Limiting to {c.MAX_MESSAGES_PER_RUN} messages" + # ) + # new_toots = new_toots[-c.MAX_MESSAGES_PER_RUN:] if c.SEND and len(new_toots) != 0: try: @@ -296,18 +313,18 @@ for bridge in bridges: twitter_last_id = bridge.twitter_last_id twitter_api = twitter.Api( - consumer_key=c.TWITTER_CONSUMER_KEY, - consumer_secret=c.TWITTER_CONSUMER_SECRET, - access_token_key=bridge.twitter_oauth_token, - access_token_secret=bridge.twitter_oauth_secret, - tweet_mode='extended' # Allow tweets longer than 140 raw characters + consumer_key=c.TWITTER_CONSUMER_KEY, + consumer_secret=c.TWITTER_CONSUMER_SECRET, + access_token_key=bridge.twitter_oauth_token, + access_token_secret=bridge.twitter_oauth_secret, + tweet_mode='extended' # Allow tweets longer than 140 raw characters ) try: new_tweets = twitter_api.GetUserTimeline( - since_id=bridge.twitter_last_id, - include_rts=True, - exclude_replies=False) + since_id=bridge.twitter_last_id, + include_rts=True, + exclude_replies=False) except TwitterError as e: l.error(f"@{bridge.twitter_handle}: {e}") @@ -320,7 +337,9 @@ for bridge in bridges: elif isinstance(e.message, list) and len(e.message) > 0: if e.message[0]['code'] in [89, 326]: - l.warning(f"Disabling bridge for Twitter user {bridge.twitter_handle}") + l.warning( + f"Disabling bridge for Twitter user {bridge.twitter_handle}" + ) bridge.twitter_oauth_token = None bridge.twitter_oauth_secret = None bridge.enabled = False @@ -331,7 +350,9 @@ for bridge in bridges: continue if len(new_tweets) > c.MAX_MESSAGES_PER_RUN: - l.error(f"@{bridge.twitter_handle}: Limiting to {c.MAX_MESSAGES_PER_RUN} messages") + l.error( + f"@{bridge.twitter_handle}: Limiting to {c.MAX_MESSAGES_PER_RUN} messages" + ) new_tweets = new_tweets[-c.MAX_MESSAGES_PER_RUN:] if c.SEND and len(new_tweets) != 0: @@ -399,7 +420,9 @@ for bridge in bridges: tweet_poster = TweetPoster(c.SEND, session, twitter_api, bridge) if bridge.mastodon_access_code: - l.info(f"{bridge.id}: M - {bridge.mastodon_user}@{mastodonhost.hostname}") + l.info( + f"{bridge.id}: M - {bridge.mastodon_user}@{mastodonhost.hostname}" + ) tweet_poster = TweetPoster(c.SEND, session, twitter_api, bridge) @@ -458,7 +481,6 @@ for bridge in bridges: bridge.md.last_tweet = tweet.created_at session.commit() - # # Post Instagram # @@ -489,7 +511,8 @@ for bridge in bridges: stat_recorded = True if not insta.should_skip_twitter and bridge.twitter_oauth_token: - tweet_poster = TweetPoster(c.SEND, session, twitter_api, bridge) + tweet_poster = TweetPoster(c.SEND, session, twitter_api, + bridge) try: result = tweet_poster.post(insta) @@ -524,7 +547,9 @@ if len(c.HEALTHCHECKS) >= args.worker: except Exception: pass -l.info(f"-- All done -> Total time: {worker_stat.formatted_time} / {worker_stat.items} items / {bridge_count} Bridges") +l.info( + f"-- All done -> Total time: {worker_stat.formatted_time} / {worker_stat.items} items / {bridge_count} Bridges" +) session.add(worker_stat)