Browse Source

release v0.2.1

Harlan Iverson 2 years ago
parent
commit
fbf1299d3d

+ 21 - 0
README.md

@@ -105,11 +105,32 @@ There is no UI to edit Tweet collections, but they can be created with the follo
 If no "authorized_users" is present then the collection is accessible to all users. By default the `base.html` template 
 If no "authorized_users" is present then the collection is accessible to all users. By default the `base.html` template 
 file contains a link to a collection named `swipe-all2` which should exist in `.data/collection/swipe-all2.json`.
 file contains a link to a collection named `swipe-all2` which should exist in `.data/collection/swipe-all2.json`.
 
 
+
+### Feeds
+
+This is a preview release, hard coded feeds are available in the `get_posts` method of `feeds_facade.py`.
+It includes a sample of feeds across providers and shows that they can all be represented in the same model
+and merged together into a timeline-like experience.
+
+### YouTube
+
+This is a preview release, hard coded. OAuth authentication works and should be configured on one's own YouTube account.
+'YouTube Data v3' access is required, to read channel info and followers/subscribers. It's one day old, so one really needs
+to go through the code to understand it.
+
+The developer portal provides a client secret file that should be placed in `.data/yt_client_secret.json`.
+
 ### Glitch Deployment
 ### Glitch Deployment
 
 
 The app detects if it's running in Glitch and will configure itself accordingly.
 The app detects if it's running in Glitch and will configure itself accordingly.
 Ensure that the Twitter API is configured to allow access to your project name.
 Ensure that the Twitter API is configured to allow access to your project name.
 
 
+## Running
+
+For local deployment, use Python:
+
+* `python hogumathi_app.py` or `python3 hogumathi_app.py`
+
 ## Contributing
 ## Contributing
 
 
 Since I intend to retain full copyright and re-license portions of the project I am only able to accept contributions with a copyright assignment, similar to how the Apache project works.
 Since I intend to retain full copyright and re-license portions of the project I am only able to accept contributions with a copyright assignment, similar to how the Apache project works.

+ 293 - 0
hogumathi_app.py

@@ -0,0 +1,293 @@
+import os
+import sys
+from importlib.util import find_spec
+from configparser import ConfigParser
+
+from flask import Flask, g, redirect, url_for, render_template, jsonify
+from flask_cors import CORS
+
+sys.path.append('.data/extensions')
+
+if find_spec('twitter_v2_facade'):
+    from twitter_v2_facade import twitter_app as twitter_v2
+    import oauth2_login
+    twitter_enabled = True
+else:
+    print('twitter module not found.')
+    twitter_enabled = False
+
+if find_spec('twitter_v2_live_facade'):
+    from twitter_v2_live_facade import twitter_app as twitter_v2_live
+    import oauth2_login
+    twitter_live_enabled = True
+else:
+    print('twitter live module not found.')
+    twitter_live_enabled = False
+
+if find_spec('twitter_archive_facade'):
+    from twitter_archive_facade import twitter_app as twitter_archive
+    archive_enabled = True
+else:
+    print('twitter archive module not found.')
+    archive_enabled = False
+
+if find_spec('mastodon_facade'):
+    from mastodon_facade import twitter_app as mastodon
+    mastodon_enabled = True
+else:
+    print('mastodon module not found.')
+    mastodon_enabled = False
+
+if find_spec('feeds_facade'):
+    from feeds_facade import twitter_app as feeds
+    feeds_enabled = True
+else:
+    print('feeds module not found.')
+    feeds_enabled = False
+
+if find_spec('youtube_facade'):
+    from youtube_facade import youtube_app as youtube
+    youtube_enabled = True
+else:
+    print('youtube module not found.')
+    youtube_enabled = False
+
+if find_spec('email_facade'):
+    from email_facade import messages_facade as messages
+    messages_enabled = True
+else:
+    print('messages module not found.')
+    messages_enabled = False
+
+
+if find_spec('videojs'):
+    from videojs import videojs_bp
+    videojs_enabled = True
+else:
+    print('videojs module not found.')
+    videojs_enabled = False
+
+if find_spec('visjs'):
+    from visjs import visjs_bp
+    visjs_enabled = True
+else:
+    print('messages module not found.')
+    visjs_enabled = False
+
+
+
+#messages_enabled = False
+#twitter_live_enabled = False
+
+add_account_enabled = True
+
+def import_env ():
+    cp = ConfigParser()
+    if os.path.exists('.env'):
+        with open('.env') as stream:
+            cp.read_string('[default]\n' + stream.read())
+            os.environ.update(dict(cp['default']))
+
+
+
+if __name__ == '__main__':
+    glitch_enabled = os.environ.get('PROJECT_DOMAIN') and True
+    
+    if glitch_enabled:
+        t = os.environ.get('BEARER_TOKEN')
+        if t:
+            t = t.replace('%%', '%')
+            os.environ['BEARER_TOKEN'] = t
+            
+    else:
+        import_env()
+
+    PORT = int(os.environ.get('PORT', 5000))
+    HOST = os.environ.get('HOST', '127.0.0.1')
+    
+    archive_enabled = os.environ.get('ARCHIVE_TWEETS_PATH') and True
+    
+    
+    notes_app_url = os.environ.get('NOTES_APP_URL')
+    bedrss_app_url = os.environ.get('NOTES_APP_URL')
+    
+    plots_app_url = os.environ.get('PLOTS_APP_URL')
+    videos_app_url = os.environ.get('VIDEOS_APP_URL')
+    messages_app_url = os.environ.get('MESSAGES_APP_URL')
+    
+    if not os.path.exists('.data'):
+        os.mkdir('.data')
+    
+    if not os.path.exists('.data/cache'):
+        os.mkdir('.data/cache')
+    
+    api = Flask(__name__, static_url_path='')
+    
+    
+    # HACK - environ from .env isn't set yet when the import happens. We should call an init function somewhere.
+    oauth2_login.app_access_token = os.environ.get("BEARER_TOKEN")
+    oauth2_login.app_consumer_key = os.environ.get("TWITTER_CONSUMER_KEY")
+    oauth2_login.app_secret_key = os.environ.get("TWITTER_CONSUMER_SECRET")
+
+
+    @api.before_request
+    def add_config ():
+        g.twitter_enabled = twitter_enabled
+        g.archive_enabled = archive_enabled
+        g.mastodon_enabled = mastodon_enabled
+        g.feeds_enabled = feeds_enabled
+        g.youtube_enabled = youtube_enabled
+        g.messages_enabled = messages_enabled
+        g.twitter_live_enabled = twitter_live_enabled
+        
+        g.add_account_enabled = add_account_enabled
+        
+        
+        g.glitch_enabled = glitch_enabled
+        
+        g.videojs_enabled = videojs_enabled
+        g.visjs_enabled = visjs_enabled
+        
+        if glitch_enabled:
+            g.app_url = 'https://{}.glitch.me'.format( os.environ.get('PROJECT_DOMAIN') )
+        else:
+            g.app_url = 'http://{}:{}'.format('localhost', PORT)
+            
+        if notes_app_url:
+            g.notes_app_url = notes_app_url
+        if bedrss_app_url:
+            g.bedrss_app_url = bedrss_app_url
+        if plots_app_url:
+            g.plots_app_url = plots_app_url
+        if videos_app_url:
+            g.videos_app_url = videos_app_url
+        if messages_app_url:
+            g.messages_app_url = messages_app_url
+    
+    
+    @api.context_processor
+    def inject_config ():
+        config = {}
+        config['twitter_enabled'] = twitter_enabled
+        config['archive_enabled'] = archive_enabled
+        config['mastodon_enabled'] = mastodon_enabled
+        config['feeds_enabled'] = feeds_enabled
+        config['youtube_enabled'] = youtube_enabled
+        config['messages_enabled'] = messages_enabled
+        config['twitter_live_enabled'] = twitter_live_enabled
+        
+        config['add_account_enabled'] = add_account_enabled
+        
+        config['glitch_enabled'] = glitch_enabled
+        
+        config['videojs_enabled'] = videojs_enabled
+        config['visjs_enabled'] = visjs_enabled
+
+        if notes_app_url:
+            config['notes_app_url'] = notes_app_url
+        if bedrss_app_url:
+            config['bedrss_app_url'] = bedrss_app_url
+        
+        if plots_app_url:
+            config['plots_app_url'] = plots_app_url
+        if videos_app_url:
+            config['videos_app_url'] = videos_app_url
+        if messages_app_url:
+            config['messages_app_url'] = messages_app_url
+            
+        
+        return config
+    
+    
+    @api.context_processor
+    def add_nav_items_to_template_context ():
+        nav_items = []
+        
+        route_nav = g.get('route_nav')
+        
+        if route_nav:
+            nav_items += route_nav
+            
+        module_nav = g.get('module_nav')
+        
+        if module_nav:
+            nav_items += module_nav
+            
+        #nav_items.sort(key = lambda ni: ni['order'])
+        
+        return dict(
+            nav_items = nav_items
+        )
+    
+    api.secret_key = os.environ.get('FLASK_SECRET')
+    
+    api.config['TEMPLATES_AUTO_RELOAD'] = True
+    
+    if videojs_enabled:
+        api.register_blueprint(videojs_bp, url_prefix='/lib/videojs')
+        
+    if visjs_enabled:
+        api.register_blueprint(visjs_bp, url_prefix='/lib/visjs')
+
+    api.register_blueprint(twitter_v2, url_prefix='/twitter')
+    
+    if archive_enabled:
+        api.register_blueprint(twitter_archive, url_prefix='/twitter-archive')
+    
+    if mastodon_enabled:
+        api.register_blueprint(mastodon, url_prefix='/mastodon')
+        
+    if feeds_enabled:
+        if not os.path.exists('.data/cache/feeds'):
+            os.mkdir('.data/cache/feeds')
+        
+        api.register_blueprint(feeds, url_prefix='/feeds')
+    
+    if youtube_enabled:
+        api.register_blueprint(youtube, url_prefix='/youtube')
+    
+    if messages_enabled:
+        api.register_blueprint(messages, url_prefix='/messages')
+        
+    if twitter_live_enabled:
+        api.register_blueprint(twitter_v2_live, url_prefix='/twitter-live')
+    
+    CORS(api)
+    
+    @api.get('/login.html')
+    def get_login_html ():
+        opengraph_info = dict(
+            type = 'webpage', # threads might be article
+            url = g.app_url,
+            title = 'Hogumathi',
+            description = 'An app for Twitter, Mastodon, YouTube, etc; Open Source.'
+        )
+        
+        return render_template('login.html', opengraph_info=opengraph_info)
+    
+    @api.get('/')
+    def index ():
+        return redirect(url_for('.get_login_html'))
+    
+    @api.get('/brand/<brand_id>.html')
+    def get_brand_html (brand_id):
+        brands = {
+            'ispoogedaily': {
+                'id': 'ispoogedaily',
+                'display_name': 'iSpooge Daily',
+                'accounts': [
+                    'twitter:14520320',
+                    'mastodon:mastodon.cloud:109271381872332822',
+                    'youtube:UCUMn9G0yzhQWXiRTOmPLXOg'
+                ]
+            }
+        }
+        
+        brand = brands.get(brand_id.lower())
+        
+        if not brand:
+            return '{"error": "no such brand."}', 404
+        
+        return jsonify(brand)
+        
+    api.run(port=PORT, host=HOST)

+ 90 - 140
mastodon_facade.py

@@ -1,6 +1,6 @@
 from configparser import ConfigParser
 from configparser import ConfigParser
 import base64
 import base64
-from flask import json, Response, render_template, request, send_from_directory, Blueprint, url_for, session, redirect
+from flask import json, Response, render_template, request, send_from_directory, Blueprint, url_for, session, redirect, g
 from flask_cors import CORS
 from flask_cors import CORS
 import sqlite3
 import sqlite3
 import os
 import os
@@ -17,6 +17,10 @@ import dateutil.tz
 
 
 import requests
 import requests
 
 
+from mastodon_source import MastodonAPSource
+from mastodon_v2_types import Status
+
+from view_model import FeedItem, FeedItemAction, PublicMetrics
 
 
 DATA_DIR='.data'
 DATA_DIR='.data'
 
 
@@ -25,54 +29,81 @@ twitter_app = Blueprint('mastodon_facade', 'mastodon_facade',
     static_url_path='',
     static_url_path='',
     url_prefix='/')
     url_prefix='/')
 
 
-
-
-
-def mastodon_model (post_data):
-    # retweeted_by, avi_icon_url, display_name, handle, created_at, text
-    
+@twitter_app.before_request
+def add_module_nav_to_template_context ():
     
     
-    user = post_data['account']
-    
-    source_url = post_data['url']
-    
-    avi_icon_url = user['avatar']
-    
-    url = url_for('.get_tweet_html', tweet_id=post_data['id'])
+    me = request.args.get('me')
     
     
-
+    if me and me.startswith('mastodon:'):
+        youtube_user = session[ me ]
+        
+        g.module_nav = [
+            dict(
+                href = url_for('.get_timeline_home_html', me=me),
+                label = 'Public Timeline',
+                order = 100
+            ),
+            dict(
+                href = url_for('.get_bookmarks_html', me=me),
+                label = 'Bookmarks',
+                order = 200
+            )
+        ]
+
+
+
+def mastodon_model_dc_vm (post_data: Status) -> FeedItem:
+    """
+    This is the method we should use. The others should be refactored out.
+    """
+    
+    user = post_data.account
+    
+    source_url = post_data.url
+    avi_icon_url = user.avatar
+    url = url_for('mastodon_facade.get_tweet_html', tweet_id=post_data.id)
+    
+    actions = {
+        'bookmark': FeedItemAction('mastodon_facade.post_tweet_bookmark', {'tweet_id': post_data.id}),
+        'delete_bookmark': FeedItemAction('mastodon_facade.delete_tweet_bookmark', {'tweet_id': post_data.id}),
         
         
-    t = {
-        'id': post_data['id'],
+        'retweet': FeedItemAction('mastodon_facade.post_tweet_retweet', {'tweet_id': post_data.id})
+    }
+    
+    t = FeedItem(
+        id = post_data.id,
         # hugely not a fan of allowing the server's HTML out. can we sanitize it ourselves?
         # hugely not a fan of allowing the server's HTML out. can we sanitize it ourselves?
-        'html': post_data['content'], 
-        'url': url,
-        'source_url': source_url,
-        'created_at': post_data['created_at'],
-        'avi_icon_url': avi_icon_url,
-        'display_name': user['display_name'],
-        'handle': user['acct'],
+        html = post_data.content, 
+        url = url,
+        source_url = source_url,
+        created_at = post_data.created_at,
+        avi_icon_url = avi_icon_url,
+        display_name = user.display_name,
+        handle = user.acct,
         
         
-        'author_url': url_for('.get_profile_html', user_id = user['id'], me='mastodon:mastodon.cloud:109271381872332822'),
-        'source_author_url':  user['url'],
+        author_url = url_for('mastodon_facade.get_profile_html', user_id = user.id, me='mastodon:mastodon.cloud:109271381872332822'),
+        source_author_url =  user.url,
         
         
-        'public_metrics': {
-            'reply_count': post_data['replies_count'],
-            'retweet_count': post_data['reblogs_count'],
-            'like_count': post_data['favourites_count']
-        },
+        public_metrics =  PublicMetrics(
+            reply_count = post_data.replies_count,
+            retweet_count = post_data.reblogs_count,
+            like_count = post_data.favourites_count
+        ),
         
         
-        'activity': post_data
-    }
+        actions = actions,
+        
+        debug_source_data = post_data
+    )
     
     
 
 
     return t
     return t
-    
-    
 
 
 def register_app (instance):
 def register_app (instance):
+    endpoint_url = g.app_url
+    redirect_uri = endpoint_url + og_url_for('.get_loggedin_html')
+    
     client_name = os.environ.get('MASTODON_CLIENT_NAME')
     client_name = os.environ.get('MASTODON_CLIENT_NAME')
-    redirect_uri = 'http://localhost:5004/mastodon/logged-in.html'
+
     
     
     url = f'https://{instance}/api/v1/apps'
     url = f'https://{instance}/api/v1/apps'
     
     
@@ -200,7 +231,7 @@ def get_logged_in_html ():
     
     
     session[me] = session_info
     session[me] = session_info
     
     
-    return redirect(url_for('.get_latest_html', me=me))
+    return redirect(url_for('.get_timeline_home_html', me=me))
     
     
     #return resp.text, resp.status_code
     #return resp.text, resp.status_code
 
 
@@ -266,8 +297,13 @@ def post_tweet_retweet (tweet_id):
     return resp.text, resp.status_code
     return resp.text, resp.status_code
 
 
 @twitter_app.get('/status/<tweet_id>.html')
 @twitter_app.get('/status/<tweet_id>.html')
-def get_tweet_html (tweet_id):
-    return 'tweet: ' + tweet_id
+@twitter_app.get('/statuses.html')
+def get_tweet_html (tweet_id = None):
+    if not tweet_id:
+        ids = request.args.get('ids').split(',')
+        return f'tweets: {ids}'
+    else:
+        return 'tweet: {tweet_id}'
 
 
 
 
 @twitter_app.get('/profile/<user_id>.html')
 @twitter_app.get('/profile/<user_id>.html')
@@ -314,7 +350,7 @@ def get_profile_html (user_id):
     #tweets = tweet_source.get_timeline("public", timeline_params)
     #tweets = tweet_source.get_timeline("public", timeline_params)
     
     
     max_id = tweets[-1]["id"] if len(tweets) else ''
     max_id = tweets[-1]["id"] if len(tweets) else ''
-    tweets = list(map(mastodon_model, tweets))
+    tweets = list(map(mastodon_model_dc_vm, tweets))
     
     
     print("max_id = " + max_id)
     print("max_id = " + max_id)
     
     
@@ -343,22 +379,15 @@ def get_profile_html (user_id):
 
 
 @twitter_app.route('/latest.html', methods=['GET'])
 @twitter_app.route('/latest.html', methods=['GET'])
 def get_timeline_home_html (variant = "reverse_chronological"):
 def get_timeline_home_html (variant = "reverse_chronological"):
-    # retweeted_by, avi_icon_url, display_name, handle, created_at, text
     me = request.args.get('me')
     me = request.args.get('me')
     mastodon_user = session[me]
     mastodon_user = session[me]
     
     
+    instance = mastodon_user.get('instance')
     token = mastodon_user.get('access_token')
     token = mastodon_user.get('access_token')
-    max_id = request.args.get('max_id')
-    
-    # comes from token or session
-    user_id = "ispoogedaily"
-    
-    if not token:
-        print("No token provided or found in environ.")
-        return Response('{"err": "No token."}', mimetype="application/json", status=400) 
     
     
-    
-    tweet_source = MastodonAPSource("https://mastodon.cloud", token)
+    max_id = request.args.get('max_id')
+
+    tweet_source = MastodonAPSource(f"https://{instance}", token)
     
     
     timeline_params = {
     timeline_params = {
         'local': True
         'local': True
@@ -367,10 +396,10 @@ def get_timeline_home_html (variant = "reverse_chronological"):
     if max_id:
     if max_id:
         timeline_params['max_id'] = max_id
         timeline_params['max_id'] = max_id
     
     
-    tweets = tweet_source.get_timeline("public", timeline_params)
+    tweets = tweet_source.get_timeline("public", timeline_params, return_dataclasses=True)
     
     
-    max_id = tweets[-1]["id"] if len(tweets) else ''
-    tweets = list(map(mastodon_model, tweets))
+    max_id = tweets[-1].id if len(tweets) else ''
+    tweets = list(map(mastodon_model_dc_vm, tweets))
     
     
     print("max_id = " + max_id)
     print("max_id = " + max_id)
     
     
@@ -382,9 +411,7 @@ def get_timeline_home_html (variant = "reverse_chronological"):
             'next_page_url': url_for('.get_timeline_home_html', max_id=max_id, me=me)
             'next_page_url': url_for('.get_timeline_home_html', max_id=max_id, me=me)
         }
         }
     
     
-    user = {
-            'id': user_id
-        }
+    user = {}
     
     
     #return Response(response_json, mimetype='application/json')
     #return Response(response_json, mimetype='application/json')
     
     
@@ -398,30 +425,19 @@ def get_bookmarks_html ():
     me = request.args.get('me')
     me = request.args.get('me')
     mastodon_user = session[me]
     mastodon_user = session[me]
     
     
-    instance = mastodon_user['instance']
-    access_token = mastodon_user["access_token"]
+    instance = mastodon_user.get('instance')
+    token = mastodon_user.get('access_token')
     
     
     max_id = request.args.get('max_id')
     max_id = request.args.get('max_id')
 
 
-    headers = {
-       'Authorization': f'Bearer {access_token}'
-    }
-    params = {}
+    tweet_source = MastodonAPSource(f"https://{instance}", token)
     
     
-    url = f'https://{instance}/api/v1/bookmarks'
-    
-    params = {}
+    tweets = tweet_source.get_bookmarks(max_id=max_id, return_dataclasses=True)
     
     
-    if max_id:
-        params['max_id'] = max_id
-    
-    resp = requests.get(url, params=params, headers=headers)
-
-    tweets = json.loads(resp.text)
     #tweets = tweet_source.get_timeline("public", timeline_params)
     #tweets = tweet_source.get_timeline("public", timeline_params)
     
     
-    max_id = tweets[-1]["id"] if len(tweets) else ''
-    tweets = list(map(mastodon_model, tweets))
+    max_id = tweets[-1].id if len(tweets) else ''
+    tweets = list(map(mastodon_model_dc_vm, tweets))
     
     
     print("max_id = " + max_id)
     print("max_id = " + max_id)
     
     
@@ -483,69 +499,3 @@ def delete_tweet_bookmark (tweet_id):
     resp = requests.post(url, headers=headers)
     resp = requests.post(url, headers=headers)
     
     
     return resp.text, resp.status_code
     return resp.text, resp.status_code
-
-
-class MastodonAPSource:
-    def __init__ (self, endpoint, token):
-        self.endpoint = endpoint
-        self.token = token
-        super().__init__()
-
-    def get_timeline (self, path = "home", params = {}):
-
-        url = self.endpoint + "/api/v1/timelines/" + path
-        params = {
-            **params
-        }
-        
-        
-        headers = {"Authorization": "Bearer {}".format(self.token)}
-        
-        response = requests.get(url, params=params, headers=headers)
-        
-        print(response)
-        
-        response_json = json.loads(response.text)
-        
-        return response_json
-    
-    # nice parallel is validation
-    # https://github.com/moko256/twitlatte/blob/master/component_client_mastodon/src/main/java/com/github/moko256/latte/client/mastodon/MastodonApiClientImpl.kt
-    
-    def get_mentions_timeline (self):
-        # /api/v1/notifications?exclude_types=follow,favourite,reblog,poll,follow_request
-        return
-        
-    def get_favorited_timeline (self):
-        # /api/v1/notifications?exclude_types=follow,reblog,mention,poll,follow_request
-        return
-        
-    def get_conversations_timeline (self):
-        # /api/v1/conversations
-        return
-        
-    def get_bookmarks_timeline (self):
-        # /account/bookmarks
-        return
-        
-    def get_favorites_timeline (self):
-        # /account/favourites
-        return
-    
-    
-    def get_user_statuses (self, account_id):
-        # /api/v2/search?type=statuses&account_id=
-        # /api/v1/accounts/:id/statuses
-        return
-    
-    def get_statuses (self, ids):
-        # /api/v1/statuses/:id
-        return
-        
-    def get_profile (self, username):
-        # /api/v1/accounts/search
-        return self.search('@' + username, result_type='accounts')
-        
-    def search (q, result_type = None, following = False):
-        # /api/v2/search
-        return

+ 159 - 0
mastodon_source.py

@@ -0,0 +1,159 @@
+from typing import List
+from dataclasses import asdict, replace
+from dacite import from_dict
+
+import json
+
+import requests
+
+from mastodon_v2_types import Status, Conversation, Context
+
+# https://github.com/mastodon/documentation/blob/master/content/en/api/guidelines.md#paginating-through-api-responses-pagination
+class MastodonAPSource:
+    def __init__ (self, endpoint, token):
+        self.endpoint = endpoint
+        self.token = token
+        super().__init__()
+        
+    def get_status_collection (self, path,
+        params = {}, return_dataclasses = False):
+        
+        url = self.endpoint + path
+        params = {
+            **params
+        }
+        
+        headers = {"Authorization": "Bearer {}".format(self.token)}
+        
+        response = requests.get(url, params=params, headers=headers)
+        
+        #print(response)
+        
+        response_json = json.loads(response.text)
+        
+        try:
+            print('trying to force timeline response to Status type')
+            typed_resp = list(map(lambda s: from_dict(data=s, data_class=Status), response_json))
+        except:
+            raise 'error forcing status to type'
+        
+        if return_dataclasses:
+            return typed_resp
+        else:
+            response_json = list(map(lambda s: asdict(s), typed_resp))
+            
+        return response_json
+
+    def get_timeline (self, path = "home", params = {}, return_dataclasses=False):
+        
+        collection_path = "/api/v1/timelines/" + path
+        params = {
+            **params
+        }
+        
+        return self.get_status_collection(collection_path, params = params, return_dataclasses=return_dataclasses)
+        
+    
+    # nice parallel is validation
+    # https://github.com/moko256/twitlatte/blob/master/component_client_mastodon/src/main/java/com/github/moko256/latte/client/mastodon/MastodonApiClientImpl.kt
+    
+    def get_mentions_timeline (self):
+        # /api/v1/notifications?exclude_types=follow,favourite,reblog,poll,follow_request
+        return
+        
+    def get_favorited_timeline (self):
+        # /api/v1/notifications?exclude_types=follow,reblog,mention,poll,follow_request
+        return
+        
+    def get_conversations (self,
+            max_id = None, since_id = None, min_id = None, limit = None
+            ) -> List[Conversation]:
+        """
+        Use get_status_context on the last_status for a conversation.
+        """
+        
+        url = f'{self.instance}/api/v1/conversations'
+        # https://docs.joinmastodon.org/methods/conversations/
+        # Use HTTP Link header for pagination.
+        headers = {
+            'Authorization': f'Bearer {self.token}'
+        }
+        params = cleandict({
+            'max_id': max_id,
+            'since_id': since_id,
+            'min_id': min_id,
+            'limit': limit
+        })
+        
+        resp = requests.get(url, params=params, headers=headers)
+        response_json = json.loads(resp.text)
+        conversations = list(map(lambda c: from_dict(data=c, data_class=Conversation), response_json))
+        
+        return conversations
+    
+    def get_status_context (self, status_id) -> Context:
+        """
+        Returns all parents and direct children.
+        """
+        url = f'{self.instance}/api/v1/statuses/{status_id}/context'
+        headers = {
+            'Authorization': f'Bearer {self.token}'
+        }
+        
+        resp = requests.get(url, headers=headers)
+        response_json = json.loads(resp.text)
+        context = from_dict(data=response_json, data_class=Context)
+        
+        return context
+    
+    def get_bookmarks (self, 
+        max_id=None, return_dataclasses=False):
+        
+        collection_path = '/api/v1/bookmarks'
+        params = {}
+        
+        if max_id:
+            params['max_id'] = max_id
+        
+        return self.get_status_collection(collection_path, params = params, return_dataclasses=return_dataclasses)
+        
+    def get_favorites_timeline (self):
+        # /account/favourites
+        return
+    
+    
+    def get_user_statuses (self, account_id, 
+        max_id = None, return_dataclasses=False):
+        # /api/v2/search?type=statuses&account_id=
+        # /api/v1/accounts/:id/statuses
+        
+        url = f'{self.endpoint}/api/v1/accounts/{account_id}/statuses'
+        headers = {
+           'Authorization': f'Bearer {self.token}'
+        }
+        params = {}
+        
+        if max_id:
+            params['max_id'] = max_id
+        
+        return self.get_status_collection(collection_path, params = params, return_dataclasses=return_dataclasses)
+    
+    def get_statuses (self, ids):
+        # /api/v1/statuses/:id
+        return
+        
+    def get_profile (self, username):
+        # /api/v1/accounts/search
+        return self.search('@' + username, result_type='accounts')
+        
+    def search (q, result_type = None, following = False):
+        # /api/v2/search
+        return
+        
+def cleandict(d):
+    if isinstance(d, dict):
+        return {k: cleandict(v) for k, v in d.items() if v is not None}
+    elif isinstance(d, list):
+        return [cleandict(v) for v in d]
+    else:
+        return d

+ 132 - 0
mastodon_v2_types.py

@@ -0,0 +1,132 @@
+from typing import Optional, List, Dict
+from dataclasses import dataclass
+
+Url = str
+Date = str
+Html = str
+
+StatusId = str
+AccountId = str
+
+@dataclass
+class Account:
+    id: AccountId
+    username: str
+    acct: str
+    url: Url
+    display_name: str
+    
+    note: Html
+    
+    avatar: Url
+    avatar_static: Url
+    
+    header: Url
+    header_static: Url
+    
+    locked: bool
+    
+    fields: List[Dict]
+    emojis: List[Dict]
+    
+    bot: bool
+    group: bool
+    
+    created_at: Date
+    
+    statuses_count: int
+    followers_count: int
+    following_count: int
+    
+    last_status_at: Optional[Date] = None
+    
+    discoverable: Optional[bool] = None
+    noindex: Optional[bool] = None
+    moved: Optional[bool] = None
+    suspended: Optional[bool] = None
+    limited: Optional[bool] = None
+    
+
+Card = Dict
+
+MediaId = str
+BlurHash = str
+
+@dataclass
+class MediaAttachment:
+    """
+    https://docs.joinmastodon.org/entities/MediaAttachment/
+    """
+    id: MediaId
+    type: str
+    url: Url
+    preview_url: Url
+    meta: Dict # Description: Metadata returned by Paperclip.
+    
+    blurhash: BlurHash
+    
+    description: Optional[str] = None # HACK: API doc doesn't say it can be null
+    text_url: Optional[str] = None
+    remote_url: Optional[Url] = None
+    
+
+@dataclass
+class Status:
+    id: StatusId
+    uri: Url
+    created_at: Date
+    account: Account
+    content: Html
+    
+    visibility: str
+    sensitive: bool
+    spoiler_text: str
+    
+    media_attachments: List[MediaAttachment]
+    
+    reblogs_count: int
+    favourites_count: int
+    replies_count: int
+    
+    url: Optional[Url] = None
+    in_reply_to_id: Optional[StatusId] = None
+    in_reply_to_account_id: Optional[AccountId] = None
+    
+    reblog: Optional['Status'] = None
+    
+    card: Optional[Card] = None
+    
+    text: Optional[str] = None
+    
+    edited_at: Optional[Date] = None
+    
+    favourited: Optional[bool] = None
+    reblogged: Optional[bool] = None
+    muted: Optional[bool] = None
+    bookmarked: Optional[bool] = None
+    pinned: Optional[bool] = None
+    filtered: Optional[bool] = None
+    
+
+@dataclass
+class Context:
+    ancestors: List[Status]
+    descendants: List[Status]
+
+ConversationId = str
+
+@dataclass
+class Conversation:
+    id: ConversationId
+    unread: bool
+    accounts: List[Account]
+    last_status: Optional[Status] = None
+
+
+Tag = Dict
+
+@dataclass 
+class Search:
+    accounts: List[Account]
+    statuses: List[Status]
+    hashtags: List[Tag]

+ 5 - 7
oauth2_login.py

@@ -245,7 +245,9 @@ def refresh_token ():
 
 
 @oauth2_login.route('/refresh-token', methods=['GET'])
 @oauth2_login.route('/refresh-token', methods=['GET'])
 def get_twitter_refresh_token (response_format='json'):
 def get_twitter_refresh_token (response_format='json'):
-    return refresh_token()
+    refresh_token()
+    
+    return 'ok'
 
 
 
 
 
 
@@ -272,10 +274,6 @@ def get_twitter_app_refresh_token ():
         
         
         
         
     
     
-    return response.text
+    return 'ok'
+
 
 
-@oauth2_login.route('/accounts.html', methods=['GET'])
-def get_loggedin_accounts_html ():
-    twitter_accounts = dict(filter(lambda e: e[0].startswith('twitter:'), session.items()))
-    
-    return jsonify(twitter_accounts)

+ 5 - 1
requirements.txt

@@ -1,3 +1,4 @@
+dacite                    ~= 1.6.0
 Flask                     ~= 2.1.2
 Flask                     ~= 2.1.2
 Flask-Cors                ~= 3.0.10
 Flask-Cors                ~= 3.0.10
 Flask-Session             ~= 0.4.0
 Flask-Session             ~= 0.4.0
@@ -5,4 +6,7 @@ json-stream               ~= 1.3.0
 python-dateutil           ~= 2.8.2
 python-dateutil           ~= 2.8.2
 requests                  ~= 2.28.0
 requests                  ~= 2.28.0
 requests-oauthlib         ~= 1.3.1
 requests-oauthlib         ~= 1.3.1
-dataclass-type-validator  ~= 0.1.2
+google-api-python-client  ~= 2.70.0
+google-auth               ~= 2.15.0
+google-auth-httplib2      ~= 0.1.0
+google-auth-oauthlib      ~= 0.8.0

+ 3 - 0
sample-env.txt

@@ -17,6 +17,9 @@ TWITTER_CONSUMER_SECRET=
 # Path to tweet.js converted into json (barely works, bolting these two things together)
 # Path to tweet.js converted into json (barely works, bolting these two things together)
 ARCHIVE_TWEETS_PATH=
 ARCHIVE_TWEETS_PATH=
 
 
+# API key for the app to use if the user has not logged in.
+GOOGLE_DEVELOPER_KEY=
+
 # For development on localhost set value to 1
 # For development on localhost set value to 1
 #OAUTHLIB_INSECURE_TRANSPORT=1
 #OAUTHLIB_INSECURE_TRANSPORT=1
 #PORT=5004
 #PORT=5004

+ 43 - 0
static/tweets-ui.js

@@ -1,3 +1,46 @@
+function swapVideoPlayer(imgEl, videoUrl, videoType) {
+  
+  if (videoType == 'video/youtube' || videoType == 'video/bitchute') {
+    
+	if (videoType == 'video/youtube') {
+		videoUrl += '?enablejsapi=1&widgetid=1&modestbranding=1&playsinline=true&showinfo=0&autoplay=1&rel=0&mute=0';
+	} else if (videoType == 'video/bitchute') {
+		videoUrl += '?autoplay=1';
+	}
+	
+    var iframe = document.createElement('iframe');
+	iframe.src = videoUrl;
+  iframe.height = imgEl.height;
+  iframe.width = imgEl.width;	
+  iframe.setAttribute('allowfullscreen','allowfullscreen');
+  iframe.setAttribute('allow', 'autoplay');
+    iframe.style.border = '0';
+	imgEl.replaceWith(iframe);
+	
+	
+	
+
+	
+    return iframe;
+  }
+  
+  var vid = document.createElement('video');
+  var src = document.createElement('source');
+  vid.appendChild(src);
+
+  vid.poster = imgEl.src;
+  vid.controls = "controls";
+  vid.height = imgEl.height;
+  vid.width = imgEl.width;
+  vid.playsInline = true;
+  vid.autoplay = true;
+    
+  src.src = videoUrl;
+  src.type = videoType;
+
+  imgEl.replaceWith(vid);
+  return vid;
+}
 
 
 function noteCardFormat (tweet, annotation) {
 function noteCardFormat (tweet, annotation) {
 	if (!tweet) { return ""; }
 	if (!tweet) { return ""; }

+ 27 - 26
templates/base.html

@@ -3,16 +3,30 @@
 <head>
 <head>
 	<meta charset="utf-8">
 	<meta charset="utf-8">
 	<meta name="viewport" content="width=device-width, initial-scale=1">
 	<meta name="viewport" content="width=device-width, initial-scale=1">
-	<link rel="stylesheet" href="{{ url_for('.static', filename='tachyons.min.css') }}">
-	<script src="{{ url_for('.static', filename='htmx.js') }}"></script>
-	<script src="{{ url_for('.static', filename='sweetalert2.js') }}"></script>
+	<link rel="stylesheet" href="{{ url_for('static', filename='tachyons.min.css') }}">
+	<script src="{{ url_for('static', filename='htmx.js') }}"></script>
+	<script src="{{ url_for('static', filename='sweetalert2.js') }}"></script>
+	
+	{% if visjs_enabled %}
+	
+	<script src="{{ url_for('visjs.static', filename='vis-timeline-graph2d.min.js') }}"></script>
+	<link href="{{ url_for('visjs.static', filename='vis-timeline-graph2d.min.css') }}" rel="stylesheet" type="text/css" />
+	
+	{% endif %}
 	
 	
 	{% block head %}
 	{% block head %}
 	<title>{{ title | default('No Title') }}</title>
 	<title>{{ title | default('No Title') }}</title>
 	{% endblock %}
 	{% endblock %}
 	
 	
-	<link rel="stylesheet" href="{{ url_for('.static', filename='theme/base.css') }}">
-	<link rel="stylesheet" href="{{ url_for('.static', filename='theme/user-14520320.css') }}">
+	<link rel="stylesheet" href="{{ url_for('static', filename='theme/base.css') }}">
+	<link rel="stylesheet" href="{{ url_for('static', filename='theme/user-14520320.css') }}">
+	
+	{% if opengraph_info %}
+	{% for prop, content in opengraph_info.items() %}
+	<meta property="og:{{prop}}" content="{{content}}" />
+	{% endfor %}
+	{% endif %}
+	
 <script>
 <script>
 const Toast = Swal.mixin({
 const Toast = Swal.mixin({
   toast: true,
   toast: true,
@@ -26,6 +40,7 @@ const Toast = Swal.mixin({
   }
   }
 })
 })
 </script>
 </script>
+
 </head>
 </head>
 <body>
 <body>
 
 
@@ -34,28 +49,12 @@ Flexbox makes logical sense but we'll go with a table-based display
 
 
 -->
 -->
 
 
-<div class="nav" style="position: fixed; width: 25%">
+<div class="nav" style="position: fixed; width: 25%; padding: 4px">
 <ul>
 <ul>
-	{% if twitter_user %}
-	<li><a href="{{ url_for('.get_timeline_home_html') }}">Latest Tweets</a></li>
-	<li><a href="{{ url_for('.get_conversations_html') }}">DMs</a></li>
-	<li><a hx-push-url="/mentions.html?me={{ me }}" hx-get="/twitter/data/mentions/{{ twitter_user.id }}?me={{ me }}" hx-target="#tweets" hx-swap="outerHTML">Mentions</a></li>
-	<!--
-	<li><a hx-get="/twitter/data/thread/1592514352690900992?me={{ me }}" hx-target="#tweets" hx-swap="outerHTML">Test Thread</a></li>
-	<li><a hx-get="/twitter/data/conversation/1592596557009801216?me={{ me }}" hx-target="#tweets" hx-swap="outerHTML">Test Conversation</a></li>
-	<li><a hx-get="/twitter/data/tweets?ids=1592637236147027970,1592474342289330176&amp;me={{ me }}" hx-target="#tweets" hx-swap="outerHTML">Test Tweets</a></li>
-	-->
-	<li><a href="{{ url_for('.get_bookmarks_html') }}">Bookmarks</a></li>
-	<li><a href="{{ url_for('.get_collection_html', collection_id='swipe-all2') }}">Test Collection</a></li>
-	
-	
-	
-	<li><a href="{{ url_for('twitter_v2_facade.oauth2_login.get_logout_html') }}">Logout ({{me}})</a></li
-	{% endif %}
-	
-	{% if mastodon_user %}
-	<li><a href="{{ url_for('.get_timeline_home_html', me=me) }}">Public Timeline</a></li>
-	<li><a href="{{ url_for('.get_bookmarks_html', me=me) }}">Bookmarks</a></li>
+	{% if nav_items %}
+		{% for nav_item in nav_items|sort(attribute='order') %}
+		<li><a href="{{ nav_item.href }}">{{ nav_item.label }}</a></li>
+		{% endfor %}
 	{% endif %}
 	{% endif %}
 	
 	
 	<li><a href="javascript:document.body.classList.toggle('theme-dark')">Toggle Dark Mode</a></li>
 	<li><a href="javascript:document.body.classList.toggle('theme-dark')">Toggle Dark Mode</a></li>
@@ -75,6 +74,8 @@ Flexbox makes logical sense but we'll go with a table-based display
 -->
 -->
 {% endif %}
 {% endif %}
 
 
+<h2>Accounts</h2>
+
 {% include "partial/user-picker.html" %}
 {% include "partial/user-picker.html" %}
 
 
 {% if add_account_enabled %}
 {% if add_account_enabled %}

+ 3 - 5
templates/following.html

@@ -1,9 +1,7 @@
 {% extends "base.html" %}
 {% extends "base.html" %}
 
 
 {% block content %}
 {% block content %}
-<ul>
-{% for follower in following %}
-	<li><a href="{{ url_for('.get_profile_html', user_id=follower) }}">{{ follower }}</a></li>
-{% endfor %}
-</ul>
+
+{% include "partial/users-list.html" %}
+
 {% endblock %}
 {% endblock %}

+ 21 - 4
templates/login.html

@@ -1,3 +1,10 @@
+
+{% extends "base.html" %}
+
+{% block content %}
+
+<div class="w-80" style="background-color: white; border: 1px solid black; padding: 16px; margin: 4px">
+
 <h1>Login</h1>
 <h1>Login</h1>
 
 
 {% if glitch_enabled %}
 {% if glitch_enabled %}
@@ -20,9 +27,13 @@
 		<p><a href="mailto:biz@harlanji.com?subject=mastodon%20server">Get your own managed Mastodon server</a></p>
 		<p><a href="mailto:biz@harlanji.com?subject=mastodon%20server">Get your own managed Mastodon server</a></p>
 		</li>
 		</li>
 {% endif %}
 {% endif %}
-{% if email_enabled %}
-	<li><form method="GET" action="{{ url_for('email_facade.get_login_html') }}">
-		<button type="submit">Email</button> <input name="address" placeholder="me@example.com"></form>
+{% if messages_enabled %}
+	<li><form method="POST" action="{{ url_for('messages_facade.post_api_login') }}">
+		<button type="submit">Email</button> <input name="host" placeholder="smtp.example.com">
+		<input name="user" placeholder="me@example.com">
+		<input name="pass" type="password" placeholder="pa$$word123">
+		</form>
+		<p>This will use IMap and SMTP over SSL.</p>
 		</li>
 		</li>
 {% endif %}
 {% endif %}
 {% if youtube_enabled %}
 {% if youtube_enabled %}
@@ -37,4 +48,10 @@
 
 
 {% endif %}
 {% endif %}
 
 
-{% include "partial/user-picker.html" %}
+<h2>Logged In Accounts</h2>
+
+{% include "partial/user-picker.html" %}
+
+</div>
+
+{% endblock %}

+ 6 - 0
templates/partial/compose-form.html

@@ -1,4 +1,10 @@
+
+{% if twitter_user %}
+<form class="compose" hx-post="{{ url_for('twitter_v2_facade.post_tweets_create', me=me) }}" hx-swap="outerHTML">
+{% else %}
 <form class="compose" hx-post="{{ url_for('.post_tweets_create', me=me) }}" hx-swap="outerHTML">
 <form class="compose" hx-post="{{ url_for('.post_tweets_create', me=me) }}" hx-swap="outerHTML">
+{% endif %}
+
 <h2>Compose</h2>
 <h2>Compose</h2>
 <ul>
 <ul>
 	<li><textarea name="text" placeholder="Only the finest..." style="width: 100%" onchange="this.onkeyup()" onkeyup="this.form.querySelector('.compose-length').innerHTML = this.value.length" rows="6" cols="30"></textarea>
 	<li><textarea name="text" placeholder="Only the finest..." style="width: 100%" onchange="this.onkeyup()" onkeyup="this.form.querySelector('.compose-length').innerHTML = this.value.length" rows="6" cols="30"></textarea>

+ 84 - 4
templates/partial/timeline-tweet.html

@@ -32,6 +32,7 @@
 	</div>
 	</div>
 	{% endif %}
 	{% endif %}
 	
 	
+	{% if not skip_embed_replies %}
 	{% if tweet.replied_tweet %}
 	{% if tweet.replied_tweet %}
 	<p style="color: silver">
 	<p style="color: silver">
 	Replying to:
 	Replying to:
@@ -50,10 +51,13 @@
 	<p style="color: silver">
 	<p style="color: silver">
 	Replying to:
 	Replying to:
 	</p>
 	</p>
+	{% if tweet.actions.view_replied_tweet %}
 	<p class="reply_to w-100" style="border: 1px solid silver; padding: 6px">
 	<p class="reply_to w-100" style="border: 1px solid silver; padding: 6px">
-		<a href="{{ url_for('.get_tweet_html', tweet_id=tweet.replied_tweet_id, view='replies', marked_reply=tweet.id ) }}">View in Thread</a>.
+		<a href="{{ url_for(tweet.actions.view_replied_tweet.route, **tweet.actions.view_replies.route_params) }}">View in Thread</a>.
 	</p>
 	</p>
 	{% endif %}
 	{% endif %}
+	{% endif %}
+	{% endif %}
 	
 	
 	{% if tweet.note %}
 	{% if tweet.note %}
 	<p class="note w-100" style="border: 1px solid black; background-color: yellow; padding: 6px">
 	<p class="note w-100" style="border: 1px solid black; background-color: yellow; padding: 6px">
@@ -77,7 +81,37 @@
 		<p>VIDEOS</p>
 		<p>VIDEOS</p>
 		<ul>
 		<ul>
 		{% for video in tweet.videos %}
 		{% for video in tweet.videos %}
-			<li><img class="w-100" src="{{ video.preview_image_url }}" referrerpolicy="no-referrer" onclick="this.src='{{ video.image_url }}'"></li>
+			<li><img class="w-100" 
+			       src="{{ video.preview_image_url }}" referrerpolicy="no-referrer"
+				   
+				   
+				   {% if video.image_url %}
+				   onclick="this.src='{{ video.image_url }}'; this.onclick = undefined" 
+				   {% endif %}
+				   
+				   {% if video.url %}
+				   ondblclick="swapVideoPlayer(this, '{{ video.url }}', '{{ video.content_type }}')"
+				   {% endif %}
+				   >
+				   
+				   <dl>
+				    {% if video.duration_str  %}
+					<dt>Duration</dt>
+					<dd>{{ video.duration_str }}</dt>
+					{% elif video.duration_ms  %}
+					<dt>Duration</dt>
+					<dd>{{ video.duration_ms / 1000 / 60 }} minutes</dt>
+					{% endif %}
+				   </dl>
+				   
+				   {% if video.public_metrics and video.public_metrics.view_count %}
+				   <p class="w-100">
+						view count: {{ video.public_metrics.view_count }}
+				   </p>
+				   {% endif %}
+				   
+				   </li>
+				   
 		{% endfor %}
 		{% endfor %}
 		</ul>
 		</ul>
 
 
@@ -108,23 +142,69 @@
 
 
 		<p class="w-100">
 		<p class="w-100">
 		{% for k, v in tweet.public_metrics.items() %}
 		{% for k, v in tweet.public_metrics.items() %}
+			{% if v != None %}
 			{{ k.replace('_count', 's').replace('ys', 'ies').replace('_', ' ') }}: {{ v }}, 
 			{{ k.replace('_count', 's').replace('ys', 'ies').replace('_', ' ') }}: {{ v }}, 
+			{% endif %}
 		{% endfor %}
 		{% endfor %}
 		
 		
 		</p>
 		</p>
 		{% endif %}
 		{% endif %}
 		
 		
+		
+		
 		{% if tweet.non_public_metrics %}
 		{% if tweet.non_public_metrics %}
 		
 		
 
 
 		
 		
 		<p class="w-100">
 		<p class="w-100">
 			{% for k, v in tweet.non_public_metrics.items() %}
 			{% for k, v in tweet.non_public_metrics.items() %}
-				{{ k.replace('_count', 's').replace('ys', 'ies').replace('_', ' ') }}: {{ v }}, 
+				{% if v != None %}
+				{{ k.replace('_count', 's').replace('ys', 'ies').replace('_', ' ') }}: {{ v }},
+				{% endif %}				
 			{% endfor %}
 			{% endfor %}
 			
 			
 		</p>
 		</p>
 		{% endif %}
 		{% endif %}
+		
+		{% if tweet.attachments %}
+		<ul>
+		{% for a in tweet.attachments %}
+			{% if a.content_type == 'application/vnd-hogumathi.livestream-details+json' %}
+			<li class="livestream-details">
+				<dl>
+					
+					{% if a.content.scheduled_start_time %}
+					<dt>Scheduled Start Time</dt>
+					<dd>{{ a.content.scheduled_start_time }}</dd>
+					{% endif %}
+					
+					{% if a.content.start_time %}
+					<dt>Start Time</dt>
+					<dd>{{ a.content.start_time }}</dd>
+					{% endif %}
+					
+					{% if a.content.chat_embed_url %}
+					<dt>Chat</dt>
+					<dd>
+						<iframe class="w-100" height="400" src="{{ a.content.chat_embed_url }}" referrerpolicy="origin"></iframe>
+					</dd>
+					{% endif %}
+					
+					
+				</dl>
+			</li>
+			{% else %}
+			<li><a href="{{ a.url }}">{{ a.name }}</a> {{ a.content_type }} ({{ a.size }})
+			{% endif %}
+			</li>
+		{% endfor %}
+		</ul>
+		{% endif %}
 	
 	
-	
+		{% if show_conversation_id %}
+		<p class="w-100">
+			Conversation: {{ tweet.conversation_id }}
+			
+		</p>
+		{% endif %}
 </div>
 </div>

+ 1 - 1
templates/partial/tweets-carousel.html

@@ -1,4 +1,4 @@
-<script src="{{ url_for('.static', filename='tweets-ui.js') }}"></script>
+<script src="{{ url_for('static', filename='tweets-ui.js') }}"></script>
 <script>
 <script>
 
 
 {% if notes_app_url %}
 {% if notes_app_url %}

+ 236 - 28
templates/partial/tweets-timeline.html

@@ -1,4 +1,8 @@
-<script src="{{ url_for('.static', filename='tweets-ui.js') }}"></script>
+
+
+<script src="{{ url_for('static', filename='tweets-ui.js') }}"></script>
+
+
 <script>
 <script>
 
 
 {% if notes_app_url %}
 {% if notes_app_url %}
@@ -6,24 +10,112 @@ var notesAppUrl = {{ notes_app_url | tojson }}
 {% endif %}
 {% endif %}
 
 
 	if (!window['dataset']) {
 	if (!window['dataset']) {
+		{% if visjs_enabled %}
+		window.dataset = new vis.DataSet();
+		{% else %}
 		window.dataset = {
 		window.dataset = {
 			items: [],
 			items: [],
 			update: function (items) {
 			update: function (items) {
 				dataset.items = dataset.items.concat(items);
 				dataset.items = dataset.items.concat(items);
+			},
+			get: function () {
+				return items;
 			}
 			}
 		}
 		}
+		{% endif %}
 	}
 	}
 </script>
 </script>
 
 
+{% if twitter_live_enabled and visjs_enabled %}
+
+<div class="w-100" style="position: sticky; top: 20px; background-color: silver; padding: 20px 0; margin: 10px 0;">
+		<div id="visualization"></div>
+	
+</div>
+
+{% endif %}
+
 <ul id="tweets" class="tweets w-75 center z-0">
 <ul id="tweets" class="tweets w-75 center z-0">
 
 
 {% for tweet in tweets %}
 {% for tweet in tweets %}
 
 
 <li class="tweet w-100 dt {% if tweet.is_marked %}marked{% endif %}">
 <li class="tweet w-100 dt {% if tweet.is_marked %}marked{% endif %}">
 <script>
 <script>
-	dataset.update([
-		{{ tweet | tojson }}
-	]);
+	function feed_item_to_activity (fi) {
+		
+		var group = 'tweet';
+		var y = 1;
+		if (fi.retweeted_tweet_id) {
+			group = 'retweet';
+			y = 2;
+		} else if (fi.replied_tweet_id) {
+			group = 'reply';
+			y = 3;
+		}
+		
+		return {
+			//'id': fi.id,
+			'x': new Date(fi.created_at),
+			'y': y,
+			'group': group,
+			'feed_item': fi
+		}
+	}
+	
+	function feed_item_to_likes (fi) {
+		
+		if ( !fi['public_metrics'] || !fi.public_metrics['like_count'] ) {
+			return;
+		}
+		
+		var group = 'likes';
+		var y = fi.public_metrics.like_count;
+		
+		
+		return {
+			//'id': fi.id,
+			'x': new Date(fi.created_at),
+			'y': y,
+			'group': group,
+			'feed_item': fi
+		}
+	}
+	
+	function feed_item_to_replies (fi) {
+		
+		if ( !fi['public_metrics'] || !fi.public_metrics['reply_count'] ) {
+			return;
+		}
+		
+		var group = 'replies';
+		var y = fi.public_metrics.reply_count;
+		
+		
+		return {
+			//'id': fi.id,
+			'x': new Date(fi.created_at),
+			'y': y,
+			'group': group,
+			'feed_item': fi
+		}
+	}
+	
+	var feedItem = {{ tweet | tojson }};
+	var plotItems = [];
+	
+	var likesPoint = feed_item_to_likes(feedItem);
+	if (likesPoint) { plotItems.push(likesPoint); }
+	
+	var repliesPoint = feed_item_to_replies(feedItem);
+	if (repliesPoint) { plotItems.push(repliesPoint); }
+	
+	if (!plotItems.length) {
+		plotItems.push(feed_item_to_activity(feedItem))
+	}
+	
+	
+	
+	dataset.update(plotItems);
 
 
 </script>
 </script>
 
 
@@ -46,25 +138,48 @@ var notesAppUrl = {{ notes_app_url | tojson }}
 		
 		
 		<p class="tweet-actions-box">
 		<p class="tweet-actions-box">
 		
 		
-		<a href="{{ url_for('.get_tweet_html', tweet_id=tweet.id, view='replies') }}">replies</a>
+		
+		{% if tweet.actions.view_replies %}
+		<a href="{{ url_for(tweet.actions.view_replies.route, **tweet.actions.view_replies.route_params) }}">replies</a>
+		|
+		{% endif %}
+		
+
+		
+		
+		{% if show_thread_controls and tweet.conversation_id %}
+		{% with tweet=tweets[0] %}
+		{% if tweet.actions.view_thread %}
+		<a href="{{ url_for(tweet.actions.view_thread.route, **tweet.actions.view_thread.route_params) }}">author thread</a>
 		|
 		|
-		{% if show_thread_controls %}
-		<a href="{{ url_for('.get_tweet_html', tweet_id=tweet.conversation_id, view='thread') }}">author thread</a>
+		{% endif %}
+		{% if tweet.actions.view_conversation %}
+		<a href="{{ url_for(tweet.actions.view_conversation.route, **tweet.actions.view_conversation.route_params) }}">full convo</a>
 		|
 		|
-		<a href="{{ url_for('.get_tweet_html', tweet_id=tweet.conversation_id, view='conversation') }}">full convo</a>
+		{% endif %}
+		{% endwith %}
+		{% endif %}
+		
+		{% if tweet.actions.view_activity %}
+		<a href="{{ url_for(tweet.actions.view_activity.route, **tweet.actions.view_activity.route_params) }}">activity</a>
 		|
 		|
 		{% endif %}
 		{% endif %}
-		{% if twitter_user %}
-		<a hx-post="{{ url_for('.post_tweet_retweet', tweet_id=tweet.id) }}">retweet</a>
+		
+		{% if tweet.actions.retweet %}
+		<a hx-post="{{ url_for(tweet.actions.retweet.route, **tweet.actions.retweet.route_params) }}">retweet</a>
 		|
 		|
 		{% endif %}
 		{% endif %}
-		{% if twitter_user or mastodon_user %}
-		<a hx-post="{{ url_for('.post_tweet_bookmark', tweet_id=tweet.id, me=me) }}">bookmark</a>
+		
+		{% if tweet.actions.bookmark %}
+		<a hx-post="{{ url_for(tweet.actions.bookmark.route, **tweet.actions.bookmark.route_params) }}">bookmark</a>
+		{% if tweet.actions.delete_bookmark %}
 		[
 		[
-		<a hx-delete="{{url_for('.delete_tweet_bookmark', tweet_id=tweet.id, me=me)}}">-</a>
+		<a hx-delete="{{ url_for(tweet.actions.delete_bookmark.route, **tweet.actions.delete_bookmark.route_params) }}">-</a>
 		]
 		]
+		{% endif %}
 		|
 		|
 		{% endif %}
 		{% endif %}
+		
 		<a class="tweet-action copy-formatted" href="javascript:copyTweetToClipboard('{{ tweet.id }}')">copy formatted</a>
 		<a class="tweet-action copy-formatted" href="javascript:copyTweetToClipboard('{{ tweet.id }}')">copy formatted</a>
 		{% if notes_app_url %}
 		{% if notes_app_url %}
 		|
 		|
@@ -95,15 +210,6 @@ var notesAppUrl = {{ notes_app_url | tojson }}
 		Loading more tweets...
 		Loading more tweets...
 		</span>
 		</span>
 
 
-		<script>
-			var profileDataEl = document.querySelector('#profile-data');
-			
-			if (window['dataset'] && profileDataEl) {
-				profileDataEl.innerHTML = dataset.items.filter(i => 'public_metrics' in i).map(i => i.public_metrics.like_count).join(', ');
-			}
-
-
-		</script>
 		</center>
 		</center>
 	</li>
 	</li>
 	
 	
@@ -115,19 +221,121 @@ var notesAppUrl = {{ notes_app_url | tojson }}
 		Go to Next Page
 		Go to Next Page
 		</a>
 		</a>
 		
 		
+		
+		</center>
+	
+	</li>
+	
+{% endif %}
+	<li style="display: none">
 		<script>
 		<script>
+			// https://stackoverflow.com/questions/22663353/algorithm-to-remove-extreme-outliers-in-array
+			// we should remove outliers on the X axis. That will mainly be RTs with old dates.
+			// we might also be able to get the date of RT as opposed to OG tweet date.
+			
+			// https://towardsdatascience.com/ways-to-detect-and-remove-the-outliers-404d16608dba
 			var profileDataEl = document.querySelector('#profile-data');
 			var profileDataEl = document.querySelector('#profile-data');
 			
 			
 			if (window['dataset'] && profileDataEl) {
 			if (window['dataset'] && profileDataEl) {
-				profileDataEl.innerHTML = dataset.items.filter(i => 'public_metrics' in i).map(i => i.public_metrics.like_count).join(', ');
+				profileDataEl.innerHTML = dataset.get().filter(i => 'public_metrics' in i).map(i => i.public_metrics.like_count).join(', ');
 			}
 			}
 
 
-
+			{% if visjs_enabled %}
+			if (window.profileActivity) {
+				window.profileActivity.fit()
+			}
+			{% endif %}
 		</script>
 		</script>
-		</center>
-	
 	</li>
 	</li>
+</ul>
+
+			{% if twitter_live_enabled and visjs_enabled %}
+			
+	<script>
+
+
+
+function onClick (e) {
+  // we need to scan the dataset between min/max x/y
+  // 
+  // TODO we want to scale these based on the zoom level / pixel values
+  //
+  // FIXME sometimes we get several points:
+  // We could also go for the closest point within the bound.
+  // Perhaps cycle through upon multiple clicks.
+  // For now we can just zoom in closer.
+  //
+  // range: graph2d.components[3].options.dataAxis.left.range.max
+  // fixing this is lower priority since it is currently static.
+  graph2d = window.profileActivity;
+  
+  var timeWindow = graph2d.getWindow();
+  var windowInSeconds = (timeWindow.end - timeWindow.start) / 1000;
+  
+  var pixelWidth = graph2d.dom.centerContainer.offsetWidth;
+  
+  var secondsPerPixel = windowInSeconds / pixelWidth;
+  
+  console.log('secondsPerPixel = ' + secondsPerPixel);
+  
+  
+  //var MAX_TIME_DIFF  = 1000 * 60 * 60;
+  
+  var MAX_TIME_DIFF = 10 * secondsPerPixel * 1000;
+  var MAX_VALUE_DIFF = 10;
+  
+  console.log(`click. value=${e.value[0]}, time=${e.time}`);
+  console.log(e);
+  
+  var nearbyItems = dataset.get({filter: function (item) {
+	var timeDiff = new Date(item.x).getTime() - e.time.getTime();
+	var valueDiff = item.y - e.value[0];
 	
 	
-{% endif %}
+	return Math.abs(timeDiff) < MAX_TIME_DIFF 
+		   && Math.abs(valueDiff) < MAX_VALUE_DIFF;
+	
+  }});
+  
+  //console.log([e.time, e.value[0]]);
+  console.log('nearby points:');
+  console.log(nearbyItems);
+}
+
+
+var container = document.getElementById('visualization');
+
+
+
+var options = {
+  sort: false,
+  sampling:false,
+  style:'points',
+  dataAxis: {
+		  width: '88px',
 
 
-</ul>
+	  //visible: false,
+	  left: {
+		  range: {
+			  min: 0, max: 10
+		  }
+	  }
+  },  
+  drawPoints: {
+	  enabled: true,
+	  size: 6,
+	  style: 'circle' // square, circle
+  },
+  defaultGroup: 'Scatterplot',
+  graphHeight: '50px',
+  width: '100%'
+};
+
+var groups = [{id: 'feed_item'}];
+
+window.profileActivity = new vis.Graph2d(container, window.dataset, groups, options);
+
+window.profileActivity.on('click', onClick);
+
+	</script>
+			
+			{% endif %}

+ 96 - 0
templates/partial/user-card.html

@@ -0,0 +1,96 @@
+<div class="user w-100" style="border: 1px solid black; background-color: white; margin: 6px 0; padding: 4px;">
+	
+	<div style="float: right; margin-top: 0; text-align: right">
+		{% if user.profile_image_url %}
+		<img style="float: right; margin-left: 4px;" src="{{ user.profile_image_url }}">
+	{% endif %}
+
+	{% if user.username %}
+	<pre style="display: inline; color: #0f0f0f; font-weight: bold; ">@{{ user.username }}</pre>
+	<br>
+	{% endif %}
+	<span><pre style="display: inline; color: #0f0f0f;">{{ user.id }}</pre></span>
+	
+	</div>
+	
+	<p style="margin-top: 0">
+		
+		
+		{% if user.actions and user.actions.view_profile %}
+		{% with view_profile=user.actions.view_profile %}
+		<a href="{{ url_for( view_profile.route, **view_profile.route_params ) }}">
+		{% endwith %}
+		{% elif user.url %}
+		<a href="{{ user.url }}">
+		{% else %}
+		<a href="{{ url_for('twitter_v2_facade.get_profile_html', user_id=user.id) }}">
+		{% endif %}
+		<strong>{{ user.name }}</strong></a>
+		
+		{% if user.verified %}
+		<small>[verified]</small>
+		{% endif %}
+		
+		{% if user.protected %}
+		<small>[protected]</small>
+		{% endif %}
+	</p>
+	
+		
+	{% if user.description %}
+	<div>
+	
+		{{ user.description }}
+	</div>
+	{% endif %}
+	
+	
+	{% if user.pinned_tweet_id %}
+	<div>
+		<p>Pinned tweet:</p>
+		<div style="border: 1px solid black">{{ user.pinned_tweet_id }}</div>
+	</div>
+	{% endif %}
+	
+	<dl>
+		{% if user.created_at %}
+		<dt>Member since</dt>
+		<dd>{{ user.created_at }}</dd>
+		{% endif %}
+		
+		{% if user.website %}
+		<dt>Website</dt>
+		<dd><a href="{{ user.website }}">{{ user.website }}</a></dd>
+		{% endif %}
+		
+		{% if user.location %}
+		<dt>Location</dt>
+		<dd>{{ user.location }}</dd>
+		{% endif %}
+		
+		{% if user.public_metrics %}
+		
+		{% if user.public_metrics.tweet_count %}
+		<dt>Tweets</dt>
+		<dd>{{ user.public_metrics.tweet_count }}</dd>
+		{% endif %}
+		
+		{% if user.public_metrics.followers_count %}
+		<dt>Followers</dt>
+		<dd>{{ user.public_metrics.followers_count }}</dd>
+		{% endif %}
+		
+		{% if user.public_metrics.following_count %}
+		<dt>Following</dt>
+		<dd>{{ user.public_metrics.following_count }}</dd>
+		{% endif %}
+		
+		{% if user.public_metrics.listed_count %}
+		<dt>Listed</dt>
+		<dd>{{ user.public_metrics.listed_count }}</dd>
+		{% endif %}
+		
+		{% endif %}
+	</dl>
+	
+</div>

+ 11 - 1
templates/partial/user-picker.html

@@ -1,4 +1,4 @@
-<h2>Accounts</h2>
+
 
 
 <ul>
 <ul>
 	{% for k, v in session.items() %}
 	{% for k, v in session.items() %}
@@ -8,9 +8,19 @@
 		{% if mastodon_enabled and k.startswith('mastodon:') %}
 		{% if mastodon_enabled and k.startswith('mastodon:') %}
 			<li><a href="{{ url_for('mastodon_facade.get_timeline_home_html', me=k) }}">{{ k }}</a>
 			<li><a href="{{ url_for('mastodon_facade.get_timeline_home_html', me=k) }}">{{ k }}</a>
 		{% endif %}
 		{% endif %}
+		{% if youtube_enabled and k.startswith('youtube:') %}
+			<li><a href="{{ url_for('youtube_facade.get_latest_html', me=k) }}">{{ k }}</a>
+		{% endif %}
+		{% if messages_enabled and k.startswith('messages:') %}
+			<li><a href="{{ url_for('messages_facade.get_latest2_html', me=k) }}">{{ k }}</a>
+		{% endif %}
 	{% endfor %}
 	{% endfor %}
 	{% if archive_enabled %}
 	{% if archive_enabled %}
 		<li><a href="{{ url_for('twitter_archive_facade.get_profile_html', user_id=0) }}">Twitter archive</a>
 		<li><a href="{{ url_for('twitter_archive_facade.get_profile_html', user_id=0) }}">Twitter archive</a>
 	{% endif %}
 	{% endif %}
+	{% if feeds_enabled %}
+	<li><a href="{{ url_for('feeds_facade.get_latest_html', me=None) }}">Feeds</a>
+	{% endif %}
+	
 	
 	
 </ul>
 </ul>

+ 9 - 0
templates/partial/users-list.html

@@ -0,0 +1,9 @@
+
+<ul class="users w-75">
+{% for user in users %}
+	<li style="list-style: none">
+
+	{% include "partial/user-card.html" %}
+	</li>
+{% endfor %}
+</ul>

+ 22 - 0
templates/search.html

@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+
+{% block head %}
+	<title>Tweet Library: {{ user.id }}</title>
+{% endblock %}
+
+
+{% block content %}
+
+<div id="search" class="center z-0 w-50" style="position: sticky; top: 0; background-color: white; padding: 4px 0">
+<center>
+<form method="GET">
+	<input type="text" name="q" value="{{ request.args.q }}" placeholder="Search query...">
+	<button type="submit">Search</button>
+</form>
+</center>
+</div>
+	
+	{% include "partial/tweets-timeline.html" %}
+
+	
+{% endblock %}

+ 13 - 10
templates/tweet-collection.html

@@ -7,18 +7,21 @@
 
 
 {% block content %}
 {% block content %}
 
 
-{% if show_parent_tweet_controls %}
-<div>
-	<a href="{{ url_for('.get_tweet_html', tweet_id=tweets[0].conversation_id, view='thread') }}">author thread</a>
-	|
-	<a href="{{ url_for('.get_tweet_html', tweet_id=tweets[0].conversation_id, view='conversation') }}">full convo</a>
-</div>
-{% endif %}
 
 
-	{% with show_thread_controls=True %}
+<div class="w-100" style="height: 40px">
 	
 	
-	{% include "partial/tweets-timeline.html" %}
+	<p class="w-100" style="text-align: right">
+		
+		{% if page_nav %}
+		{% for nav_item in page_nav|sort(attribute='order') %}
+		| <a href="{{ nav_item.href }}">{{ nav_item.label }}</a>
+		{% endfor %}
+		{% endif %}
+		
+	</p>
 	
 	
-	{% endwith %}
+</div>
+	
+	{% include "partial/tweets-timeline.html" %}
 	
 	
 {% endblock %}
 {% endblock %}

+ 29 - 23
templates/user-profile.html

@@ -2,36 +2,32 @@
 
 
 {% block head %}
 {% block head %}
 	<title>Profile: {{ user.id }}</title>
 	<title>Profile: {{ user.id }}</title>
-{% endblock %}
 
 
 
 
+	
+{% endblock %}
+
 {% block content %}
 {% block content %}
 
 
-	<div id="profile-data" class="w-100" style="position: fixed; height: 30px; background-color: silver">
-	
-	Data
-	
-	</div>
-	<div id="profile-data" style="position: fixed; top: 30px; background-color: silver; width: 80%">
-		<center>
-		<img src="{{ url_for('.static', filename='fake-tweet-activity.png') }}" alt="fake tweet activity" style="width: 60%; height: 40px;">
-		</center>
-	</div>
-	fake-tweet-activity.png
 
 
-	<div class="w-100" style="margin-top: 80px">
-		<div class="w-100" style="height: 40px">
+
+	<div class="w-100">
+		{% if twitter_user %}
+		
+		{% include "partial/user-card.html" %}
+		
+		<div class="w-100" style="height: 80px;">
 			<p class="w-100" style="text-align: right">
 			<p class="w-100" style="text-align: right">
-			<a href="#">Timeline</a> 
-			|
-			<a href="#">Likes</a> 
-			|
-			<a href="#">Following</a>
-			|
-			<a href="#">Followers</a>
-			|
-			<a href="#">Activity</a>
+			
+			{% if page_nav %}
+			{% for nav_item in page_nav|sort(attribute='order') %}
+			| <a href="{{ nav_item.href }}">{{ nav_item.label }}</a>
+			{% endfor %}
+			{% endif %}
+			
 			</p>
 			</p>
+			
+			<div class="w-100">
 			<form action="{{ url_for('.get_profile_html', user_id=user.id) }}" method="GET">
 			<form action="{{ url_for('.get_profile_html', user_id=user.id) }}" method="GET">
 			
 			
 			<input type="hidden" name="me" value="{{ me }}">
 			<input type="hidden" name="me" value="{{ me }}">
@@ -43,20 +39,30 @@
 			<br>
 			<br>
 			<button type="submit">Filter</button>
 			<button type="submit">Filter</button>
 			</form>
 			</form>
+			</div>
 		</div>
 		</div>
 		
 		
+		
+		
+		{% endif %}
+		
 		{% block tab_content %}
 		{% block tab_content %}
 		
 		
 		{% if tab == "media" %}
 		{% if tab == "media" %}
 			media
 			media
 		
 		
 		{% else %}
 		{% else %}
+
+
+		
 			{% with show_thread_controls=True %}
 			{% with show_thread_controls=True %}
 			
 			
 			{% include "partial/tweets-timeline.html" %}
 			{% include "partial/tweets-timeline.html" %}
 			
 			
 			{% endwith %}
 			{% endwith %}
 			
 			
+
+			
 		{% endif %}
 		{% endif %}
 		
 		
 		{% endblock %}
 		{% endblock %}

+ 29 - 0
templates/youtube-channel.html

@@ -0,0 +1,29 @@
+{% extends "base.html" %}
+
+{% block head %}
+	<title>Tweet Library: {{ user.id }}</title>
+{% endblock %}
+
+
+{% block content %}
+
+
+
+<div class="w-100" style="height: 40px">
+	
+	<p class="w-100" style="text-align: right">
+		
+		{% if page_nav %}
+		{% for nav_item in page_nav|sort(attribute='order') %}
+		| <a href="{{ nav_item.href }}">{{ nav_item.label }}</a>
+		{% endfor %}
+		{% endif %}
+		
+	</p>
+	
+</div>
+	
+	{% include "partial/users-list.html" %}
+
+	
+{% endblock %}

+ 266 - 48
tweet_source.py

@@ -1,7 +1,13 @@
+from dataclasses import asdict
+from typing import List
+from dacite import from_dict
+
 import json
 import json
 import requests
 import requests
 import sqlite3
 import sqlite3
 
 
+from twitter_v2_types import TweetSearchResponse, DMEventsResponse, UserSearchResponse
+
 class ArchiveTweetSource:
 class ArchiveTweetSource:
     """
     """
     id, created_at, retweeted, favorited, retweet_count, favorite_count, full_text, in_reply_to_status_id_str, in_reply_to_user_id, in_reply_to_screen_nam
     id, created_at, retweeted, favorited, retweet_count, favorite_count, full_text, in_reply_to_status_id_str, in_reply_to_user_id, in_reply_to_screen_nam
@@ -29,7 +35,7 @@ class ArchiveTweetSource:
         
         
         # if the ID is not stored as a number (eg. string) then this could be a problem
         # if the ID is not stored as a number (eg. string) then this could be a problem
         if since_id:
         if since_id:
-            where_sql.append("id > ?")
+            where_sql.append("cast(id as integer) > ?")
             sql_params.append(since_id)
             sql_params.append(since_id)
             
             
         #if author_id:
         #if author_id:
@@ -46,7 +52,7 @@ class ArchiveTweetSource:
         if where_sql:
         if where_sql:
             where_sql = "where {}".format(where_sql)
             where_sql = "where {}".format(where_sql)
         
         
-        sql = "select {} from tweet {} order by created_at asc limit ?".format(sql_cols, where_sql)
+        sql = "select {} from tweet {} order by cast(id as integer) asc limit ?".format(sql_cols, where_sql)
         sql_params.append(max_results)
         sql_params.append(max_results)
         
         
         
         
@@ -63,21 +69,23 @@ class ArchiveTweetSource:
         return results
         return results
     
     
     def get_tweet (self, id_):
     def get_tweet (self, id_):
-        return self.get_tweets([id_])
+        tweets = self.get_tweets([id_])
+        if len(tweets):
+            return tweets[0]
     
     
     def get_tweets (self,
     def get_tweets (self,
                     ids):
                     ids):
                     
                     
         sql_params = []
         sql_params = []
         where_sql = []
         where_sql = []
-        if since_id:
-            ids_in_list_sql = "id in ({})".format( ','.join(['?'] * len(ids)))
-            where_sql.append(ids_in_list_sql)
-            sql_params += ids
         
         
+        ids_in_list_sql = "id in ({})".format( ','.join(['?'] * len(ids)))
+        where_sql.append(ids_in_list_sql)
+        sql_params += ids
+    
         where_sql = " and ".join(where_sql)
         where_sql = " and ".join(where_sql)
         
         
-        sql = "select * from tweet where {} limit ?".format(where_sql)
+        sql = "select * from tweet where {}".format(where_sql)
         
         
         db = self.get_db()
         db = self.get_db()
         
         
@@ -86,6 +94,8 @@ class ArchiveTweetSource:
         
         
         results = list(map(dict, cur.execute(sql, sql_params).fetchall()))
         results = list(map(dict, cur.execute(sql, sql_params).fetchall()))
         
         
+        results.sort(key=lambda t: ids.index(t['id']))
+        
         return results
         return results
     
     
     def search_tweets (self,
     def search_tweets (self,
@@ -119,24 +129,49 @@ class TwitterApiV2SocialGraph:
     def __init__ (self, token):
     def __init__ (self, token):
         self.token = token
         self.token = token
         
         
-    def get_user (user_id, is_username=False):
+    def get_user (self, user_id, is_username=False):
         # GET /2/users/:id
         # GET /2/users/:id
         # GET /2/users/by/:username
         # GET /2/users/by/:username
-        return
+        return self.get_users([user_id], is_username)
     
     
-    def get_users (user_ids, are_usernames=False):
+    def get_users (self, user_ids, are_usernames=False):
         # GET /2/users/by?usernames=
         # GET /2/users/by?usernames=
         # GET /2/users?ids=
         # GET /2/users?ids=
-        return
+        
+        
+        
+        user_fields = ["id", "created_at", "name", "username", "location", "profile_image_url", "verified", "description", "public_metrics", "protected", "pinned_tweet_id", "url"]
+        
+        params = {
+            
+            'user.fields' : ','.join(user_fields),
+
+        }
+        
+        if are_usernames:
+            url = "https://api.twitter.com/2/users/by"
+            params['usernames'] = user_ids
+        else:
+            url = "https://api.twitter.com/2/users"
+            params['ids'] = user_ids
+
+        headers = {
+            'Authorization': 'Bearer {}'.format(self.token)
+        }
+        
+        response = requests.get(url, params=params, headers=headers)
+        result = json.loads(response.text)
+        
+        return result
         
         
     def get_following (self, user_id, 
     def get_following (self, user_id, 
-                        max_results = 1000, pagination_token = None):
+                        max_results = 50, pagination_token = None, return_dataclass=False):
         # GET /2/users/:id/following
         # GET /2/users/:id/following
         
         
         url = "https://api.twitter.com/2/users/{}/following".format(user_id)
         url = "https://api.twitter.com/2/users/{}/following".format(user_id)
         
         
         
         
-        user_fields = ["created_at", "name", "username", "location", "profile_image_url", "verified"]
+        user_fields = ["id", "created_at", "name", "username", "location", "profile_image_url", "verified"]
         
         
         params = {
         params = {
             'user.fields' : ','.join(user_fields),
             'user.fields' : ','.join(user_fields),
@@ -150,25 +185,30 @@ class TwitterApiV2SocialGraph:
             params['pagination_token'] = pagination_token
             params['pagination_token'] = pagination_token
         
         
         headers = {
         headers = {
-            'Authorization': 'Bearer {}'.format(self.token),
-            'Content-Type': 'application/json'
+            'Authorization': 'Bearer {}'.format(self.token)
         }
         }
         
         
         response = requests.get(url, params=params, headers=headers)
         response = requests.get(url, params=params, headers=headers)
         result = json.loads(response.text)
         result = json.loads(response.text)
         
         
+        typed_result = from_dict(data_class=UserSearchResponse, data=result)
+        
+        if return_dataclass:
+            return typed_result
+        
+        result = cleandict(asdict(typed_result))
+        
         return result
         return result
         
         
-        return
         
         
     def get_followers (self, user_id,
     def get_followers (self, user_id,
-                        max_results = 1000, pagination_token = None):
+                        max_results = 50, pagination_token = None, return_dataclass=False):
         # GET /2/users/:id/followers
         # GET /2/users/:id/followers
         
         
         url = "https://api.twitter.com/2/users/{}/followers".format(user_id)
         url = "https://api.twitter.com/2/users/{}/followers".format(user_id)
         
         
         
         
-        user_fields = ["created_at", "name", "username", "location", "profile_image_url", "verified"]
+        user_fields = ["id", "created_at", "name", "username", "location", "profile_image_url", "verified", "description", "public_metrics", "protected", "pinned_tweet_id", "url"]
         
         
         params = {
         params = {
             'user.fields' : ','.join(user_fields),
             'user.fields' : ','.join(user_fields),
@@ -182,25 +222,97 @@ class TwitterApiV2SocialGraph:
             params['pagination_token'] = pagination_token
             params['pagination_token'] = pagination_token
         
         
         headers = {
         headers = {
-            'Authorization': 'Bearer {}'.format(self.token),
-            'Content-Type': 'application/json'
+            'Authorization': 'Bearer {}'.format(self.token)
         }
         }
         
         
         response = requests.get(url, params=params, headers=headers)
         response = requests.get(url, params=params, headers=headers)
         result = json.loads(response.text)
         result = json.loads(response.text)
         
         
+        typed_result = from_dict(data_class=UserSearchResponse, data=result)
+        
+        if return_dataclass:
+            return typed_result
+        
+        result = cleandict(asdict(typed_result))
+        
         return result
         return result
         
         
         
         
-    def follow_user (user_id, target_user_id):
+    def follow_user (self, user_id, target_user_id):
         # POST /2/users/:id/following
         # POST /2/users/:id/following
         # {target_user_id}
         # {target_user_id}
         return
         return
         
         
-    def unfollow_user (user_id, target_user_id):
+    def unfollow_user (self, user_id, target_user_id):
         # DELETE /2/users/:source_user_id/following/:target_user_id
         # DELETE /2/users/:source_user_id/following/:target_user_id
         return
         return
 
 
+class ApiV2ConversationSource:
+    def __init__ (self, token):
+        self.token = token
+    
+    def get_recent_events (self, max_results = None, pagination_token = None):
+
+        # https://developer.twitter.com/en/docs/twitter-api/direct-messages/lookup/api-reference/get-dm_events
+        url = "https://api.twitter.com/2/dm_events"
+
+        params = {
+            "dm_event.fields": "id,event_type,text,created_at,dm_conversation_id,sender_id,participant_ids,referenced_tweets,attachments",
+            "expansions": ",".join(["sender_id", "participant_ids"]),
+            
+            "user.fields": ",".join(["id", "created_at", "name", "username", "location", "profile_image_url", "url", "verified"])
+        }
+        
+        if max_results:
+            params['max_results'] = max_results
+        
+        if pagination_token:
+            params['pagination_token'] = pagination_token
+            
+        headers = {"Authorization": "Bearer {}".format(self.token)}
+        
+        response = requests.get(url, params=params, headers=headers)
+        response_json = json.loads(response.text)
+        
+        typed_resp = from_dict(data=response_json, data_class=DMEventsResponse)
+        
+        return typed_resp
+        
+    def get_conversation (self, dm_conversation_id,
+        max_results = None, pagination_token = None):
+        
+        return
+        
+    def get_conversation_with_user (self, user_id,
+        max_results = None, pagination_token = None):
+        
+        return
+    
+    def send_message (self, dm_conversation_id, text, attachments = None):
+        url = f'/2/dm_conversations/{dm_conversation_id}/messages'
+        
+        body = {
+            'text': text
+        }
+        
+        if attachments:
+            body['attachments'] = attachments
+        
+        headers = {"Authorization": "Bearer {}".format(self.token)}
+        
+        resp = requests.post(url, data=json.dumps(body), headers=headers)
+        
+        result = json.loads(resp.text)
+        
+        example_resp_text = """
+        {
+          "dm_conversation_id": "1346889436626259968",
+          "dm_event_id": "128341038123"
+        }
+        """
+        
+        return result 
+        
 class ApiV2TweetSource:
 class ApiV2TweetSource:
     def __init__ (self, token):
     def __init__ (self, token):
         self.token = token
         self.token = token
@@ -289,7 +401,7 @@ class ApiV2TweetSource:
         return result
         return result
     
     
     
     
-    def get_home_timeline (self, user_id, variant = 'reverse_chronological', max_results = 10, pagination_token = None, since_id = None):
+    def get_home_timeline (self, user_id, variant = 'reverse_chronological', max_results = 10, pagination_token = None, since_id = None, until_id = None, end_time = None) -> TweetSearchResponse:
         """
         """
         Get a user's timeline as viewed by the user themselves.
         Get a user's timeline as viewed by the user themselves.
         """
         """
@@ -297,13 +409,16 @@ class ApiV2TweetSource:
         path = 'users/{}/timelines/{}'.format(user_id, variant)
         path = 'users/{}/timelines/{}'.format(user_id, variant)
         
         
         return self.get_timeline(path, 
         return self.get_timeline(path, 
-            max_results=max_results, pagination_token=pagination_token, since_id=since_id) 
+            max_results=max_results, pagination_token=pagination_token, since_id=since_id, until_id=until_id, end_time=end_time, return_dataclass=True) 
     
     
     def get_timeline (self, path,
     def get_timeline (self, path,
         max_results = 10, pagination_token = None, since_id = None,
         max_results = 10, pagination_token = None, since_id = None,
+        until_id = None,
+        end_time = None,
         non_public_metrics = False,
         non_public_metrics = False,
         exclude_replies=False,
         exclude_replies=False,
-        exclude_retweets=False):
+        exclude_retweets=False,
+        return_dataclass=False):
         """
         """
         Get any timeline, including custom curated timelines built by Tweet Deck / ApiV11.
         Get any timeline, including custom curated timelines built by Tweet Deck / ApiV11.
         """
         """
@@ -345,14 +460,22 @@ class ApiV2TweetSource:
             exclude.append('retweets')
             exclude.append('retweets')
             
             
         if len(exclude):
         if len(exclude):
+            print(f'get_timeline exclude={exclude}')
             params['exclude'] = ','.join(exclude)
             params['exclude'] = ','.join(exclude)
         
         
         
         
+        
         if pagination_token:
         if pagination_token:
             params['pagination_token'] = pagination_token
             params['pagination_token'] = pagination_token
             
             
         if since_id:
         if since_id:
             params['since_id'] = since_id
             params['since_id'] = since_id
+            
+        if until_id:
+            params['until_id'] = until_id
+            
+        if end_time:
+            params['end_time'] = end_time
         
         
         headers = {"Authorization": "Bearer {}".format(token)}
         headers = {"Authorization": "Bearer {}".format(token)}
         
         
@@ -361,21 +484,45 @@ class ApiV2TweetSource:
         response = requests.get(url, params=params, headers=headers)
         response = requests.get(url, params=params, headers=headers)
         response_json = json.loads(response.text)
         response_json = json.loads(response.text)
         
         
-        return response_json
+        try:
+            #print(json.dumps(response_json, indent = 2))
+            typed_resp = from_dict(data=response_json, data_class=TweetSearchResponse)
+        except:
+            print('error converting response to dataclass')
+            print(json.dumps(response_json, indent = 2))
+            
+            if not return_dataclass:
+                return response_json
+            
+            raise 'error converting response to dataclass'
+        
+        if return_dataclass:
+            return typed_resp
     
     
+        checked_resp = cleandict(asdict(typed_resp))
+        
+        print('using checked response to get_timeline')
+        
+        #print(json.dumps(checked_resp, indent=2))
+        #print('og=')
+        #print(json.dumps(response_json, indent=2))
+        
+        return checked_resp
+
     def get_mentions_timeline (self, user_id,
     def get_mentions_timeline (self, user_id,
-                                max_results = 10, pagination_token = None, since_id = None):
+                                max_results = 10, pagination_token = None, since_id = None, return_dataclass=False):
                                 
                                 
         path = "users/{}/mentions".format(user_id)
         path = "users/{}/mentions".format(user_id)
         
         
         return self.get_timeline(path, 
         return self.get_timeline(path, 
-            max_results=max_results, pagination_token=pagination_token, since_id=since_id)
+            max_results=max_results, pagination_token=pagination_token, since_id=since_id, return_dataclass=return_dataclass)
     
     
     def get_user_timeline (self, user_id,
     def get_user_timeline (self, user_id,
                           max_results = 10, pagination_token = None, since_id = None,
                           max_results = 10, pagination_token = None, since_id = None,
                           non_public_metrics=False,
                           non_public_metrics=False,
                           exclude_replies=False,
                           exclude_replies=False,
-                          exclude_retweets=False):
+                          exclude_retweets=False,
+                          return_dataclass=False):
         """
         """
         Get a user's Tweets as viewed by another.
         Get a user's Tweets as viewed by another.
         """
         """
@@ -384,15 +531,16 @@ class ApiV2TweetSource:
         return self.get_timeline(path, 
         return self.get_timeline(path, 
             max_results=max_results, pagination_token=pagination_token, since_id=since_id,
             max_results=max_results, pagination_token=pagination_token, since_id=since_id,
             non_public_metrics = non_public_metrics,
             non_public_metrics = non_public_metrics,
-            exclude_replies=exclude_replies, exclude_retweets=exclude_retweets)
+            exclude_replies=exclude_replies, exclude_retweets=exclude_retweets, return_dataclass=return_dataclass)
     
     
     
     
-    def get_tweet (self, id_, non_public_metrics = False):
-        return self.get_tweets([id_], non_public_metrics = non_public_metrics)
+    def get_tweet (self, id_, non_public_metrics = False, return_dataclass=False):
+        return self.get_tweets([id_], non_public_metrics = non_public_metrics, return_dataclass=return_dataclass)
     
     
     def get_tweets (self,
     def get_tweets (self,
                     ids,
                     ids,
-                    non_public_metrics = False):
+                    non_public_metrics = False,
+                    return_dataclass = False):
                     
                     
         token = self.token
         token = self.token
         
         
@@ -426,7 +574,18 @@ class ApiV2TweetSource:
         response = requests.get(url, params=params, headers=headers)
         response = requests.get(url, params=params, headers=headers)
         response_json = json.loads(response.text)
         response_json = json.loads(response.text)
         
         
-        return response_json
+        print(json.dumps(response_json, indent=2))
+        
+        typed_resp = from_dict(data=response_json, data_class=TweetSearchResponse)
+        
+        if return_dataclass:
+            return typed_resp
+        
+        checked_resp = cleandict(asdict(typed_resp))
+
+        print('using checked response to search_tweets')
+        
+        return checked_resp
     
     
     def search_tweets (self,
     def search_tweets (self,
                        query, 
                        query, 
@@ -434,7 +593,8 @@ class ApiV2TweetSource:
                        since_id = None,
                        since_id = None,
                        max_results = 10,
                        max_results = 10,
                        sort_order = None,
                        sort_order = None,
-                       non_public_metrics = False
+                       non_public_metrics = False,
+                       return_dataclass = False
                        ):
                        ):
         
         
         token = self.token
         token = self.token
@@ -480,7 +640,22 @@ class ApiV2TweetSource:
         response = requests.get(url, params=params, headers=headers)
         response = requests.get(url, params=params, headers=headers)
         response_json = json.loads(response.text)
         response_json = json.loads(response.text)
         
         
-        return response_json
+        try:
+            typed_resp = from_dict(data=response_json, data_class=TweetSearchResponse)
+        except:
+            print('error converting tweet search response to TweetSearchResponse')
+            print(response_json)
+            
+            raise 'error converting tweet search response to TweetSearchResponse'
+            
+        if return_dataclass:
+            return typed_resp
+        
+        checked_resp = cleandict(asdict(typed_resp))
+
+        print('using checked response to search_tweets')
+        
+        return checked_resp
         
         
         
         
     
     
@@ -522,7 +697,8 @@ class ApiV2TweetSource:
                        pagination_token = None,
                        pagination_token = None,
                        since_id = None,
                        since_id = None,
                        max_results = 10,
                        max_results = 10,
-                       sort_order = None
+                       sort_order = None,
+                       return_dataclass=False
                        ):
                        ):
         
         
         # FIXME author_id can be determined from a Tweet object
         # FIXME author_id can be determined from a Tweet object
@@ -538,14 +714,16 @@ class ApiV2TweetSource:
         print("get_thread query=" + query)
         print("get_thread query=" + query)
         
         
         return self.search_tweets(query, 
         return self.search_tweets(query, 
-            pagination_token = pagination_token, since_id = since_id, max_results = max_results, sort_order = sort_order)
+            pagination_token = pagination_token, since_id = since_id, max_results = max_results, sort_order = sort_order,
+            return_dataclass=return_dataclass)
     
     
     def get_bookmarks (self, user_id,
     def get_bookmarks (self, user_id,
-                          max_results = 10, pagination_token = None, since_id = None):
+                          max_results = 10, pagination_token = None, since_id = None,
+                          return_dataclass=False):
         path = "users/{}/bookmarks".format(user_id)
         path = "users/{}/bookmarks".format(user_id)
         
         
         return self.get_timeline(path, 
         return self.get_timeline(path, 
-            max_results=max_results, pagination_token=pagination_token, since_id=since_id)
+            max_results=max_results, pagination_token=pagination_token, since_id=since_id, return_dataclass=return_dataclass)
     
     
     def get_media_tweets (self,
     def get_media_tweets (self,
                    author_id = None,
                    author_id = None,
@@ -558,7 +736,8 @@ class ApiV2TweetSource:
                    pagination_token = None,
                    pagination_token = None,
                    since_id = None,
                    since_id = None,
                    max_results = 10,
                    max_results = 10,
-                   sort_order = None
+                   sort_order = None,
+                   return_dataclass=False
                    ):
                    ):
         
         
         # FIXME author_id can be determined from a Tweet object
         # FIXME author_id can be determined from a Tweet object
@@ -599,7 +778,7 @@ class ApiV2TweetSource:
             query += "from:{} ".format(author_id)
             query += "from:{} ".format(author_id)
             
             
         return self.search_tweets(query, 
         return self.search_tweets(query, 
-            pagination_token = pagination_token, since_id = since_id, max_results = max_results, sort_order = sort_order)
+            pagination_token = pagination_token, since_id = since_id, max_results = max_results, sort_order = sort_order, return_dataclass = return_dataclass)
     
     
         
         
     def get_retweets (self, tweet_id):
     def get_retweets (self, tweet_id):
@@ -610,19 +789,58 @@ class ApiV2TweetSource:
         # GET /2/tweets/:id/quote_tweets
         # GET /2/tweets/:id/quote_tweets
         return 
         return 
         
         
-    def get_likes (self, user_id,
-                     max_results = 10, pagination_token = None, since_id = None):
+    def get_liked_tweets (self, user_id,
+                     max_results = 10, pagination_token = None, since_id = None, return_dataclass=False):
         # GET /2/users/:id/liked_tweets
         # GET /2/users/:id/liked_tweets
+        # User rate limit (User context): 75 requests per 15-minute window per each authenticated user
+        
         path = "users/{}/liked_tweets".format(user_id)
         path = "users/{}/liked_tweets".format(user_id)
         
         
         return self.get_timeline(path, 
         return self.get_timeline(path, 
-            max_results=max_results, pagination_token=pagination_token, since_id=since_id)
+            max_results=max_results, pagination_token=pagination_token, since_id=since_id, return_dataclass=return_dataclass)
         
         
-    def get_liked_by (self, tweet_id):
+    def get_liking_users (self, tweet_id,
+            max_results = None, pagination_token = None):
         #  GET /2/tweets/:id/liking_users
         #  GET /2/tweets/:id/liking_users
+        # User rate limit (User context): 75 requests per 15-minute window per each authenticated user
+
+        url = f"https://api.twitter.com/2/tweets/{tweet_id}/liking_users"
+        
+        user_fields = ["id", "created_at", "name", "username", "location", "profile_image_url", "verified", "description", "public_metrics", "protected", "pinned_tweet_id", "url"]
+        
+        expansions = None
+        
+        params = cleandict({
+            "user.fields": ','.join(user_fields),
+            "max_results": max_results,
+            "pagination_token": pagination_token,
+            "expansions": expansions,
+        })
+        
+        headers = {
+            "Authorization": f"Bearer {self.token}"
+        }
+        
+        resp = requests.get(url, headers=headers, params=params)
+        
+        response_json = json.loads(resp.text)
+        
+        return response_json
+        
+    def like_tweet (self, tweet_id):
+        #  POST /2/users/:user_id/likes
+        #  {id: tweet_id}
         return
         return
     
     
     def get_list_tweets (self, list_id):
     def get_list_tweets (self, list_id):
         # GET /2/lists/:id/tweets
         # GET /2/lists/:id/tweets
         return
         return
-    
+    
+    
+def cleandict(d):
+    if isinstance(d, dict):
+        return {k: cleandict(v) for k, v in d.items() if v is not None}
+    elif isinstance(d, list):
+        return [cleandict(v) for v in d]
+    else:
+        return d

+ 0 - 141
twitter_app.py

@@ -1,141 +0,0 @@
-import os
-from importlib.util import find_spec
-from configparser import ConfigParser
-
-from flask import Flask, g, redirect, url_for, render_template, jsonify
-from flask_cors import CORS
-
-
-if find_spec('twitter_v2_facade'):
-    from twitter_v2_facade import twitter_app as twitter_v2
-    import oauth2_login
-    twitter_enabled = True
-else:
-    print('twitter module not found.')
-    twitter_enabled = False
-
-if find_spec('twitter_archive_facade'):
-    from twitter_archive_facade import twitter_app as twitter_archive
-    archive_enabled = True
-else:
-    print('twitter archive module not found.')
-    archive_enabled = False
-
-if find_spec('mastodon_facade'):
-    from mastodon_facade import twitter_app as mastodon
-    mastodon_enabled = True
-else:
-    print('mastodon module not found.')
-    mastodon_enabled = False
-
-add_account_enabled = True
-
-def import_env ():
-    cp = ConfigParser()
-    if os.path.exists('.env'):
-        with open('.env') as stream:
-            cp.read_string('[default]\n' + stream.read())
-            os.environ.update(dict(cp['default']))
-
-
-
-if __name__ == '__main__':
-    import_env()
-
-    PORT = int(os.environ.get('PORT', 5000))
-    HOST = os.environ.get('HOST', '127.0.0.1')
-    
-    archive_enabled = os.environ.get('ARCHIVE_TWEETS_PATH') and True
-    glitch_enabled = os.environ.get('PROJECT_DOMAIN') and True
-    
-    notes_app_url = os.environ.get('NOTES_APP_URL')
-    
-    if not os.path.exists('.data'):
-        os.mkdir('.data')
-    
-    if not os.path.exists('.data/cache'):
-        os.mkdir('.data/cache')
-    
-    api = Flask(__name__, static_url_path='')
-    
-    
-    # HACK - environ from .env isn't set yet when the import happens. We should call an init function somewhere.
-    oauth2_login.app_access_token = os.environ.get("BEARER_TOKEN")
-    oauth2_login.app_consumer_key = os.environ.get("TWITTER_CONSUMER_KEY")
-    oauth2_login.app_secret_key = os.environ.get("TWITTER_CONSUMER_SECRET")
-
-
-    @api.before_request
-    def add_config ():
-        g.twitter_enabled = twitter_enabled
-        g.archive_enabled = archive_enabled
-        g.mastodon_enabled = mastodon_enabled
-        
-        g.add_account_enabled = add_account_enabled
-        
-        
-        g.glitch_enabled = glitch_enabled
-        
-        if glitch_enabled:
-            g.app_url = 'https://{}.glitch.me'.format( os.environ.get('PROJECT_DOMAIN') )
-        else:
-            g.app_url = 'http://{}:{}'.format('localhost', PORT)
-            
-        if notes_app_url:
-            g.notes_app_url = notes_app_url
-        
-
-    
-    @api.context_processor
-    def inject_config ():
-        config = {}
-        config['twitter_enabled'] = twitter_enabled
-        config['archive_enabled'] = archive_enabled
-        config['mastodon_enabled'] = mastodon_enabled
-        
-        config['add_account_enabled'] = add_account_enabled
-        
-        config['glitch_enabled'] = glitch_enabled
-
-        if notes_app_url:
-            config['notes_app_url'] = notes_app_url
-        
-        
-        return config
-    
-    api.secret_key = os.environ.get('FLASK_SECRET')
-    
-    api.config['TEMPLATES_AUTO_RELOAD'] = True
-
-    api.register_blueprint(twitter_v2, url_prefix='/twitter')
-    
-    if archive_enabled:
-        api.register_blueprint(twitter_archive, url_prefix='/twitter-archive')
-    
-    if mastodon_enabled:
-        api.register_blueprint(mastodon, url_prefix='/mastodon')
-    
-    CORS(api)
-    
-    @api.get('/login.html')
-    def get_login_html ():
-        return render_template('login.html')
-    
-    @api.get('/')
-    def index ():
-        return redirect(url_for('.get_login_html'))
-    
-    @api.get('/brand/<brand_id>.html')
-    def get_brand_html (brand_id):
-        brand = {
-            'id': 'ispoogedaily',
-            'display_name': 'iSpooge Daily',
-            'accounts': [
-                'twitter:14520320',
-                'mastodon:mastodon.cloud:109271381872332822'
-            ]
-        }
-        
-        return jsonify(brand)
-        
-    api.run(port=PORT, host=HOST)

+ 88 - 57
twitter_archive_facade.py

@@ -1,3 +1,6 @@
+from typing import List
+from dacite import from_dict
+
 from configparser import ConfigParser
 from configparser import ConfigParser
 import base64
 import base64
 from flask import Flask, json, Response, render_template, request, send_from_directory, Blueprint, url_for, g
 from flask import Flask, json, Response, render_template, request, send_from_directory, Blueprint, url_for, g
@@ -20,6 +23,8 @@ import requests
 
 
 from tweet_source import ArchiveTweetSource
 from tweet_source import ArchiveTweetSource
 
 
+from view_model import FeedItem, PublicMetrics
+
 ARCHIVE_TWEETS_PATH=os.environ.get('ARCHIVE_TWEETS_PATH', '.data/tweets.json')
 ARCHIVE_TWEETS_PATH=os.environ.get('ARCHIVE_TWEETS_PATH', '.data/tweets.json')
 
 
 
 
@@ -243,8 +248,8 @@ def tweet_model (tweet_data):
 
 
     
     
     t['public_metrics'] = {
     t['public_metrics'] = {
-        'like_count': tweet_data['favorite_count'],
-        'retweet_count': tweet_data['retweet_count'],
+        'like_count': int(tweet_data['favorite_count']),
+        'retweet_count': int(tweet_data['retweet_count']),
         'reply_count': 0,
         'reply_count': 0,
         'quote_count': 0
         'quote_count': 0
     }
     }
@@ -252,48 +257,44 @@ def tweet_model (tweet_data):
     return t
     return t
 
 
 
 
-@twitter_app.route('/data/timeline/user/<user_id>')
-def get_data_timeline_user (user_id):
-
-    pagination_token = request.args.get('pagination_token') # since_id
-    #exclude_replies = request.args.get('exclude_replies')
-    
-    #is_me = user_id == twitter['id']
-    
-    tweet_source = ArchiveTweetSource(ARCHIVE_TWEETS_PATH)
-    
-    db_tweets = tweet_source.get_user_timeline(author_id = user_id,
-                                                    since_id = pagination_token,
-                                                    #exclude_replies = exclude_replies == '1'
-                                                    )
-                                                    
-
-    tweets = list(map(tweet_model, db_tweets))
-    next_token = db_tweets[-1]['id']
+def tweet_model_vm (tweet_data) -> List[FeedItem]:
+    # retweeted_by, avi_icon_url, display_name, handle, created_at, text
     
     
-    query = {}
+    """
+    {"id": "797839193", "created_at": "2008-04-27T04:00:27", "retweeted": 0, "favorited": 0, "retweet_count": "0", "favorite_count": "0", "full_text": "Putting pizza on. Come over any time!", "in_reply_to_status_id_str": null, "in_reply_to_user_id": null, "in_reply_to_screen_name": null, "author_id": "14520320"}, {"id": "797849979", "created_at": "2008-04-27T04:27:46", "retweeted": 0, "favorited": 0, "retweet_count": "0", "favorite_count": "0", "full_text": "hijacked!@!!!", "in_reply_to_status_id_str": null, "in_reply_to_user_id": null, "in_reply_to_screen_name": null, "author_id": "14520320"}
+    """
+    t = FeedItem(
+        id = tweet_data['id'],
+        text = tweet_data['full_text'],
+        created_at = tweet_data['created_at'],
+        author_is_verified = False,
+        
+        conversation_id = tweet_data['id'],
+        
+        avi_icon_url = '',
+        
+        display_name = 'Archive User',
+        handle = '!archive',
+        
+        url = url_for('.get_tweet_html', tweet_id = tweet_data['id']),
+        
+        author_url = url_for('.get_profile_html', user_id='0'),
+        author_id = '0',
+        
+        source_url = '!source_url',
+        source_author_url = '!source_author_url',
+        #'is_edited': len(tweet_data['edit_history_tweet_ids']) > 1
+        
+        public_metrics = PublicMetrics(
+            like_count = int(tweet_data['favorite_count']),
+            retweet_count = int(tweet_data['retweet_count']),
+            reply_count = 0,
+            quote_count = 0
+        )
+    )
     
     
-    if next_token:
-        query = {
-            **query,
-            
-            'next_data_url': url_for('.get_data_timeline_user', user_id=user_id, pagination_token=next_token)
-        }
+    return t
     
     
-    if 'HX-Request' in request.headers:
-        user = {
-            'id': user_id
-        }
-        
-        return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
-    else:
-        response_body = json.dumps({
-            'tweets': tweets,
-            'query': query
-        })
-        return Response(response_body, mimetype='application/json')
-
-
 
 
 @twitter_app.route('/profile/<user_id>.html', methods=['GET'])
 @twitter_app.route('/profile/<user_id>.html', methods=['GET'])
 def get_profile_html (user_id):
 def get_profile_html (user_id):
@@ -309,7 +310,7 @@ def get_profile_html (user_id):
                                                     )
                                                     )
                                                     
                                                     
 
 
-    tweets = list(map(tweet_model, db_tweets))
+    tweets = list(map(tweet_model_vm, db_tweets))
     next_token = db_tweets[-1]['id']
     next_token = db_tweets[-1]['id']
 
 
 
 
@@ -318,26 +319,50 @@ def get_profile_html (user_id):
     if next_token:
     if next_token:
         query = {
         query = {
             **query,
             **query,
-            
-            'next_data_url': url_for('.get_data_timeline_user', user_id=user_id, pagination_token=next_token, exclude_replies=1),
+            'next_data_url': url_for('.get_profile_html', user_id=user_id , pagination_token=next_token),
             'next_page_url': url_for('.get_profile_html', user_id=user_id , pagination_token=next_token)
             'next_page_url': url_for('.get_profile_html', user_id=user_id , pagination_token=next_token)
         }
         }
     
     
     profile_user = {
     profile_user = {
             'id': user_id
             'id': user_id
         }
         }
-    theme = {
-        'name': None,
-        'body': {'background': 'floralwhite'},
-        'timeline': {'tweet': {'background': 'white',
-                               'border': '1px solid silver'}}
-    }
+
+    if 'HX-Request' in request.headers:
+        user = {
+            'id': user_id
+        }
+        
+        return render_template('partial/tweets-timeline.html', user = profile_user, tweets = tweets, query = query)
+    else:
+        return render_template('user-profile.html', user = profile_user, tweets = tweets, query = query)
+
+@twitter_app.get('/tweet/<tweet_id>.html')
+@twitter_app.get('/tweets.html')
+def get_tweet_html (tweet_id = None):
+    
+    output_format = request.args.get('format')
+    
+    if not tweet_id:
+        ids = request.args.get('ids').split(',')
+    else:
+        ids = [tweet_id]
+        
+    tweet_source = ArchiveTweetSource(ARCHIVE_TWEETS_PATH)
     
     
-    return render_template('user-profile.html', user = profile_user, tweets = tweets, query = query, theme=theme)
+    db_tweets = tweet_source.get_tweets(ids)
+                                                    
 
 
-@twitter_app.get('/tweet.html')
-def get_tweet_html (tweet_id):
-    return ''
+    tweets = list(map(tweet_model_vm, db_tweets))
+    query = {}
+    profile_user = {}
+    
+    
+    if output_format == 'feed.json':
+        return jsonify(dict(
+            data = tweets
+            ))
+    else:
+        return render_template('search.html', user = profile_user, tweets = tweets, query = query)
 
 
 @twitter_app.route('/latest.html', methods=['GET'])
 @twitter_app.route('/latest.html', methods=['GET'])
 def get_timeline_home_html (variant = "reverse_chronological", pagination_token=None):
 def get_timeline_home_html (variant = "reverse_chronological", pagination_token=None):
@@ -366,7 +391,7 @@ def post_media_upload ():
 def get_tweets_search (response_format='json'):
 def get_tweets_search (response_format='json'):
     
     
     search = request.args.get('q')
     search = request.args.get('q')
-    limit = int(request.args.get('limit', 10))
+    limit = int(request.args.get('limit', 10000))
     offset = int(request.args.get('offset', 0))
     offset = int(request.args.get('offset', 0))
     
     
     in_reply_to_user_id = int(request.args.get('in_reply_to_user_id', 0))
     in_reply_to_user_id = int(request.args.get('in_reply_to_user_id', 0))
@@ -389,6 +414,8 @@ from tweet
         sql += " where in_reply_to_user_id = ?"
         sql += " where in_reply_to_user_id = ?"
         sql_params.append(str(in_reply_to_user_id))
         sql_params.append(str(in_reply_to_user_id))
         
         
+    sql += ' order by cast(id as integer)'
+        
     if limit:
     if limit:
         sql += ' limit ?'
         sql += ' limit ?'
         sql_params.append(limit)
         sql_params.append(limit)
@@ -432,7 +459,11 @@ from tweet
             "metaData": meta,
             "metaData": meta,
             "tweets": rows
             "tweets": rows
         }
         }
-        
+    elif response_format == 'html':
+        tweets = list(map(tweet_model_vm, tweets))
+        query = {}
+        profile_user = {}
+        return render_template('search.html', user = profile_user, tweets = tweets, query = query)
     else:
     else:
         result = {
         result = {
             "q": search,
             "q": search,
@@ -450,7 +481,7 @@ def post_tweets ():
     
     
     db = sqlite3.connect('.data/tweet.db')
     db = sqlite3.connect('.data/tweet.db')
     
     
-    db.execute('create table tweet (id, created_at, retweeted, favorited, retweet_count, favorite_count, full_text, in_reply_to_status_id_str, in_reply_to_user_id, in_reply_to_screen_name)')
+    db.execute('create table tweet (id integer, created_at, retweeted, favorited, retweet_count integer, favorite_count integer, full_text, in_reply_to_status_id_str integer, in_reply_to_user_id, in_reply_to_screen_name)')
     db.commit()
     db.commit()
     
     
     i = 0
     i = 0

File diff suppressed because it is too large
+ 326 - 644
twitter_v2_facade.py


+ 190 - 0
twitter_v2_types.py

@@ -0,0 +1,190 @@
+from typing import List, Dict, Union, Optional
+from dataclasses import dataclass, asdict
+
+Date = str
+
+Url = str
+ContentType = str
+
+TweetId = str
+UserId = str
+
+MediaKey = str
+
+Error = Dict
+
+@dataclass
+class User:
+    id: UserId
+    
+    username: Optional[str] = None
+    created_at: Optional[Date] = None
+    name: Optional[str] = None
+    verified: Optional[bool] = None
+    profile_image_url: Optional[Url] = None
+    description: Optional[str] = None
+    url: Optional[str] = None
+
+@dataclass
+class PublicMetrics:
+    retweet_count: Optional[int] = None
+    reply_count: Optional[int] = None
+    like_count: Optional[int] = None
+    quote_count: Optional[int] = None
+    
+    # on video media (only?)
+    view_count: Optional[int] = None
+
+@dataclass
+class NonPublicMetrics:
+    impression_count: Optional[int] = None
+    profile_clicks: Optional[int] = None
+    url_link_clicks: Optional[int] = None
+    user_profile_clicks: Optional[int] = None
+    
+@dataclass
+class Media:
+    media_key: MediaKey
+    type: str
+    width: int
+    height: int
+    
+@dataclass
+class PhotoMedia(Media):
+    url: Url
+    
+
+@dataclass
+class VideoMediaVariant:
+    content_type: ContentType
+    url: Url
+    bit_rate: Optional[int] = None
+
+@dataclass
+class VideoMedia (Media):
+    preview_image_url: Url
+    duration_ms: int
+    variants: List[VideoMediaVariant]
+    public_metrics: Optional[PublicMetrics] = None
+    non_public_metrics: Optional[NonPublicMetrics] = None
+
+@dataclass
+class GifMedia (Media):
+    preview_image_url: Url
+    variants: List[VideoMediaVariant]
+    public_metrics: Optional[PublicMetrics] = None
+    non_public_metrics: Optional[NonPublicMetrics] = None
+
+@dataclass
+class TweetAttachments:
+    media_keys: Optional[List[MediaKey]]
+
+@dataclass
+class MentionEntity:
+    start: int
+    end: int
+    id: UserId
+    username: str
+
+@dataclass
+class UrlEntity:
+    start: int
+    end: int
+    url: Url
+    expanded_url: Optional[Url] = None
+    display_url: Optional[str] = None
+    media_key: Optional[MediaKey] = None
+    description: Optional[str] = None
+    title: Optional[str] = None
+    unwound_url: Optional[Url] = None
+    status: Optional[int] = None
+
+@dataclass
+class TweetEntities:
+    urls: Optional[List[UrlEntity]] = None
+    mentions: Optional[List[MentionEntity]] = None
+
+@dataclass 
+class ReferencedTweet:
+    id: TweetId
+    type: str
+
+@dataclass
+class Tweet:
+    id: TweetId
+    created_at: Date
+    text: str
+    
+    conversation_id: Optional[TweetId] = None
+    in_reply_to_tweet_id: Optional[TweetId] = None
+    author_id: Optional[UserId] = None
+    referenced_tweets: Optional[List[ReferencedTweet]] = None
+    entities: Optional[TweetEntities] = None
+    public_metrics: Optional[PublicMetrics] = None
+    non_public_metrics: Optional[NonPublicMetrics] = None
+    attachments: Optional[TweetAttachments] = None
+    edit_history_tweet_ids: Optional[List[TweetId]] = None
+
+
+@dataclass
+class TweetExpansions:
+    users: Optional[List[User]] = None
+    media: Optional[List[Union[VideoMedia, PhotoMedia, GifMedia]]] = None
+    tweets: Optional[List[Tweet]] = None
+
+@dataclass
+class TweetSearchMeta:
+    count: Optional[int] = None # is this an error? different endpoint type?
+    result_count: Optional[int] = None
+    next_token: Optional[str] = None
+    newest_id: Optional[TweetId] = None
+    oldest_id: Optional[TweetId] = None
+
+@dataclass
+class TweetSearchResponse:
+    data: Optional[List[Tweet]] = None
+    meta: Optional[TweetSearchMeta] = None
+    includes: Optional[TweetExpansions] = None
+    errors: Optional[List[Error]] = None
+
+@dataclass
+class DMEvent:
+    id: str
+    created_at: str
+    event_type: str
+    dm_conversation_id: str
+    
+
+@dataclass
+class DMCMessageCreate(DMEvent):
+    sender_id: UserId
+    text: str
+    attachments: Optional[TweetAttachments] = None
+
+@dataclass
+class DMParticpantsJoin (DMEvent):
+    participant_ids: List[UserId]
+    
+
+@dataclass
+class DMParticpantsLeave (DMEvent):
+    participant_ids: List[UserId]
+
+@dataclass
+class DMEventsResponse:
+    data: List[Union[DMCMessageCreate, DMParticpantsJoin, DMParticpantsLeave]]
+    meta: Optional[TweetSearchMeta] = None # is SearchResultsMeta a general response format?
+    includes: Optional[TweetExpansions] = None
+
+
+
+
+UserSearchMeta = Dict
+
+
+@dataclass
+class UserSearchResponse:
+    data: Optional[List[User]] = None
+    meta: Optional[UserSearchMeta] = None
+    includes: Optional[TweetExpansions] = None
+    errors: Optional[List[Error]] = None

+ 158 - 0
view_model.py

@@ -0,0 +1,158 @@
+from dataclasses import dataclass, asdict, replace
+from typing import List, Dict, Optional
+
+@dataclass
+class PublicMetrics:
+    reply_count: Optional[int] = None
+    quote_count: Optional[int] = None
+    retweet_count: Optional[int] = None
+    like_count: Optional[int] = None
+    
+    # may be video only
+    view_count: Optional[int] = None
+    
+    
+    def items (self):
+        return asdict(self).items()
+    
+@dataclass
+class NonPublicMetrics:
+    impression_count: Optional[int] = None
+    user_profile_clicks: Optional[int] = None
+    url_link_clicks: Optional[int] = None
+    
+    def items (self):
+        return asdict(self).items()
+
+@dataclass
+class MediaItem:
+    type: str
+    preview_image_url: str
+    
+    media_key: Optional[str] = None
+    image_url: Optional[str] = None
+    
+    url: Optional[str] = None
+    content_type: Optional[str] = None
+    duration_ms: Optional[int] = None
+    
+    height: Optional[int] = None
+    width: Optional[int] = None
+    
+    size: Optional[int] = None
+    
+    public_metrics: Optional[PublicMetrics] = None
+
+@dataclass
+class Card:
+    display_url: Optional[str] = None
+    source_url: Optional[str] = None
+    content: Optional[str] = None
+    title: Optional[str] = None
+
+
+@dataclass
+class FeedItemAction:
+    route: str
+    route_params: Dict
+
+@dataclass
+class FeedItemAttachment:
+    name: str
+    content_type: str
+    
+    url: Optional[str] = None
+    content: Optional[object] = None
+    
+    size: Optional[int] = None
+
+@dataclass
+class FeedItem:
+    id: str
+    created_at: str
+    
+    display_name: str
+    handle: str
+    
+    text: Optional[str] = None
+    html: Optional[str] = None
+    
+    author_is_verified: Optional[bool] = None
+    url: Optional[str] = None
+    conversation_id: Optional[str] = None
+    
+    avi_icon_url: Optional[str] = None
+    
+    author_url: Optional[str] = None
+    author_id: Optional[str] = None
+    
+    source_url: Optional[str] = None
+    source_author_url: Optional[str] = None
+    
+    reply_depth: Optional[int] = 0
+    
+    is_marked: Optional[bool] = None
+    
+    card: Optional[Card] = None
+    
+    public_metrics: Optional[PublicMetrics] = None
+    non_public_metrics: Optional[NonPublicMetrics] = None
+    
+    retweeted_tweet_id: Optional[str] = None
+    
+    source_retweeted_by_url: Optional[str] = None
+    retweeted_by: Optional[str] = None
+    retweeted_by_url: Optional[str] = None
+    
+    videos: Optional[List[MediaItem]] = None
+    photos: Optional[List[MediaItem]] = None
+    
+    quoted_tweet_id: Optional[str] = None
+    quoted_tweet: Optional['FeedItem'] = None
+    
+    replied_tweet_id: Optional[str] = None
+    replied_tweet: Optional['FeedItem'] = None
+    
+    note: Optional[str] = None
+    
+    debug_source_data: Optional[Dict] = None
+    
+    attachments: Optional[List[FeedItemAttachment]] = None
+    
+    # At some point we may move to feed_item_actions when the set is known
+    actions: Optional[Dict[str, FeedItemAction]] = None
+
+# tm = FeedItem(id="1", text="aa", created_at="fs", display_name="fda", handle="fdsafas")
+
+
+@dataclass
+class FeedServiceUser:
+    id: str
+    url: str
+    name: str # display_name
+    username: str # handle
+    
+    created_at: Optional[str] = None
+    description: Optional[str] = None
+    preview_image_url: Optional[str] = None
+    website: Optional[str] = None
+    
+    actions: Optional[Dict[str, FeedItemAction]] = None 
+    
+    source_url: Optional[str] = None
+    
+    @property
+    def display_name (self) -> str:
+        return self.name
+        
+    @property
+    def handle (self) -> str:
+        return self.username
+
+def cleandict(d):
+    if isinstance(d, dict):
+        return {k: cleandict(v) for k, v in d.items() if v is not None}
+    elif isinstance(d, list):
+        return [cleandict(v) for v in d]
+    else:
+        return d

Some files were not shown because too many files changed in this diff