twitter_v2_facade.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316
  1. from typing import List
  2. from dataclasses import asdict, replace
  3. from dacite import from_dict
  4. from importlib.util import find_spec
  5. from configparser import ConfigParser
  6. import base64
  7. import sqlite3
  8. import os
  9. import json
  10. import json_stream
  11. from zipfile import ZipFile
  12. import itertools
  13. import time
  14. from io import BufferedReader
  15. import re
  16. import datetime
  17. import dateutil
  18. import dateutil.parser
  19. import dateutil.tz
  20. import requests
  21. from werkzeug.utils import secure_filename
  22. from flask import json, Response, render_template, request, send_from_directory, Blueprint, session, redirect, g, current_app, jsonify
  23. from flask_cors import CORS
  24. from tweet_source import ApiV2TweetSource, TwitterApiV2SocialGraph, ApiV2ConversationSource
  25. from twitter_v2_types import Tweet, TweetExpansions
  26. from view_model import FeedItem, FeedServiceUser, ThreadItem, FeedItemAction, MediaItem, Card, PublicMetrics, NonPublicMetrics, UnrepliedSection, cleandict
  27. import oauth2_login
  28. if find_spec('brands'):
  29. from brands import find_brand_by_account, fetch_brand_info
  30. DATA_DIR='.data'
  31. twitter_app = Blueprint('twitter_v2_facade', 'twitter_v2_facade',
  32. static_folder='static',
  33. static_url_path='',
  34. url_prefix='/')
  35. twitter_app.register_blueprint(oauth2_login.oauth2_login, url_prefix="/")
  36. twitter_app.context_processor(oauth2_login.inject_me)
  37. twitter_app.before_request(oauth2_login.add_me)
  38. url_for = oauth2_login.url_for_with_me
  39. def run_script(script_name, script_vars):
  40. script_path = './{}.py'.format(script_name)
  41. if (os.path.exists(script_path)):
  42. script_file = open(script_path, 'r')
  43. script = script_file.read()
  44. script_file.close()
  45. try:
  46. return exec(script, script_vars)
  47. except:
  48. print('error running script: {}'.format(script_name))
  49. return False
  50. False
  51. class ActivityData:
  52. def __init__ (self, user_id, db_path):
  53. self.db_path = db_path
  54. self.user_id = user_id
  55. db_exists = os.path.exists(db_path)
  56. self.db = sqlite3.connect(db_path)
  57. if not db_exists:
  58. self.init_db()
  59. return
  60. def init_db (self):
  61. self.db.execute('create table seen_user (ts, user_id)')
  62. self.db.execute('create table seen_tweet (ts, tweet_id)')
  63. return
  64. def seen_tweet (self, tweet_id):
  65. return
  66. def seen_user (self, user_id):
  67. return
  68. def add_tweet_counts (self, user_id, start, end, tweet_count):
  69. return [current_ts, user_id, start, end, tweet_count]
  70. def add_tweet_public_metrics (self, tweet_id, like_count, reply_count, retweet_count, quote_count):
  71. return
  72. def add_tweet_non_public_metrics (self, tweet_id, impression_count, click_count, link_click_count, profile_click_count):
  73. return
  74. def add_user_public_metrics (self, user_id, followers_count, following_count, tweet_count, listed_count):
  75. return
  76. class DataSet:
  77. def __init__ (self):
  78. self.items = {}
  79. return
  80. def update_items (self, items):
  81. """
  82. merges objects by ID. Asssigns an ID if none exists. Mutates OG object.
  83. """
  84. ids = []
  85. for item in items:
  86. if not 'id' in item:
  87. #item = dict(item)
  88. item['id'] = uuid.uuid4().hex
  89. else:
  90. existing_item = self.items.get( item['id'] )
  91. if existing_item:
  92. existing_item.update(item)
  93. item = existing_item
  94. self.items[ item['id'] ] = item
  95. ids.append( item['id'] )
  96. return ids
  97. def get_items (self):
  98. return self.items.values()
  99. class TwitterMetadata:
  100. def __init__ (self, data_dir):
  101. self.data_dir = data_dir
  102. os.mkdir(data_dir, exist_ok=True)
  103. def get_tweet (self, tweet_id):
  104. path = f'{self.data_dir}/tweet_{tweet_id}.json'
  105. if not os.path.exists(path):
  106. return None
  107. with open(path, 'rt') as f:
  108. return json.loads(f.read())
  109. def update_tweet (self, tweet_id, fields):
  110. tweet = self.get_tweet(tweet_id)
  111. if not tweet:
  112. tweet = {'id': tweet_id}
  113. tweet.update(fields)
  114. with open(f'{self.data_dir}/tweet_{tweet_id}.json', 'wt') as f:
  115. f.write(json.dumps(tweet))
  116. return tweet
  117. #twitter_meta = TwitterMetadata('./data/meta')
  118. @twitter_app.route('/tweets', methods=['POST'])
  119. def post_tweets_create ():
  120. user_id = g.twitter_user['id']
  121. token = g.twitter_user['access_token']
  122. text = request.form.get('text')
  123. reply_to_tweet_id = request.form.get('reply_to_tweet_id')
  124. quote_tweet_id = request.form.get('quote_tweet_id')
  125. tweet_source = ApiV2TweetSource(token)
  126. result = tweet_source.create_tweet(text, reply_to_tweet_id=reply_to_tweet_id, quote_tweet_id=quote_tweet_id)
  127. print(result)
  128. run_script('on_tweeted', {'twitter_user': g.twitter_user, 'tweet': result})
  129. if 'HX-Request' in request.headers:
  130. return render_template('partial/compose-form.html', new_tweet_id=result['data']['id'])
  131. else:
  132. response_body = json.dumps({
  133. 'result': result
  134. })
  135. return jsonify(response_body)
  136. @twitter_app.route('/tweet/<tweet_id>/retweet', methods=['POST'])
  137. def post_tweet_retweet (tweet_id):
  138. user_id = g.twitter_user['id']
  139. token = g.twitter_user['access_token']
  140. tweet_source = ApiV2TweetSource(token)
  141. result = tweet_source.retweet(tweet_id, user_id=user_id)
  142. print(result)
  143. run_script('on_tweeted', {'twitter_user': g.twitter_user, 'retweet': result})
  144. if 'HX-Request' in request.headers:
  145. return """retweeted <script>Toast.fire({
  146. icon: 'success',
  147. title: 'Retweet was sent; <a style="text-align: right" href="{}">View</a>.'
  148. });</script>""".replace('{}', url_for('.get_tweet_html', tweet_id=tweet_id))
  149. else:
  150. response_body = json.dumps({
  151. 'result': result
  152. })
  153. return jsonify(response_body)
  154. @twitter_app.route('/tweet/<tweet_id>/bookmark', methods=['POST'])
  155. def post_tweet_bookmark (tweet_id):
  156. user_id = g.twitter_user['id']
  157. token = g.twitter_user['access_token']
  158. tweet_source = ApiV2TweetSource(token)
  159. result = tweet_source.bookmark(tweet_id, user_id=user_id)
  160. print(result)
  161. if 'HX-Request' in request.headers:
  162. return """bookmarked <script>Toast.fire({
  163. icon: 'success',
  164. title: 'Tweet was bookmarked; <a style="text-align: right" href="{}">View</a>.'
  165. });</script>""".replace('{}', url_for('.get_tweet_html', tweet_id=tweet_id))
  166. else:
  167. response_body = json.dumps({
  168. 'result': result
  169. })
  170. return jsonify(response_body)
  171. @twitter_app.route('/tweet/<tweet_id>/bookmark', methods=['DELETE'])
  172. def delete_tweet_bookmark (tweet_id):
  173. user_id = g.twitter_user['id']
  174. token = g.twitter_user['access_token']
  175. tweet_source = ApiV2TweetSource(token)
  176. result = tweet_source.delete_bookmark(tweet_id, user_id=user_id)
  177. response_body = json.dumps({
  178. 'result': result
  179. })
  180. return jsonify(response_body)
  181. @twitter_app.route('/tweet/<tweet_id>.html', methods=['GET'])
  182. def get_tweet_html (tweet_id):
  183. pagination_token = request.args.get('pagination_token')
  184. view = request.args.get('view', 'replies')
  185. if g.twitter_user:
  186. token = g.twitter_user['access_token']
  187. else:
  188. token = os.environ.get('BEARER_TOKEN')
  189. tweet_source = ApiV2TweetSource(token)
  190. only_replies = view == 'replies'
  191. tweets = []
  192. if not pagination_token:
  193. tweets_response = tweet_source.get_tweet(tweet_id, return_dataclass=True)
  194. tweet = tweets_response.data[0]
  195. tweets.append(tweet_model_dc_vm(tweets_response.includes, tweet, g.me))
  196. skip_embed_replies = False
  197. if view == 'replies':
  198. replies_response = tweet_source.get_thread(tweet_id,
  199. only_replies=True,
  200. pagination_token = pagination_token,
  201. return_dataclass=True)
  202. elif view == 'thread':
  203. skip_embed_replies = True
  204. replies_response = tweet_source.get_thread(tweet_id,
  205. only_replies=False,
  206. author_id=tweets[0].author_id,
  207. pagination_token = pagination_token,
  208. return_dataclass=True)
  209. elif view == 'conversation':
  210. replies_response = tweet_source.get_thread(tweet_id,
  211. only_replies=False,
  212. pagination_token = pagination_token,
  213. return_dataclass=True)
  214. elif view == 'tweet':
  215. replies_response = None
  216. next_token = None
  217. #print("conversation meta:")
  218. #print(json.dumps(tweets_response.get('meta'), indent=2))
  219. if replies_response and replies_response.meta and replies_response.meta.result_count:
  220. includes = replies_response.includes
  221. tweets = list(map(lambda t: tweet_model_dc_vm(includes, t, g.me), replies_response.data)) + tweets
  222. next_token = replies_response.meta.next_token
  223. # this method is OK except it doesn't work if there are no replies.
  224. #tweets.append(tweet_model(includes, list(filter(lambda t: t['id'] == tweet_id, includes.get('tweets')))[0], me))
  225. #related_tweets = [] # derived from includes
  226. tweets.reverse()
  227. query = {}
  228. if next_token:
  229. query = {
  230. **query,
  231. # FIXME only_replies
  232. 'next_data_url': url_for('.get_tweet_html', tweet_id=tweet_id, pagination_token=next_token, only_replies = '1' if only_replies else '0', author_id = tweets[0].author_id),
  233. 'next_page_url': url_for('.get_tweet_html', tweet_id=tweet_id, view=view, pagination_token=next_token)
  234. }
  235. user = {
  236. }
  237. if 'HX-Request' in request.headers:
  238. # console.log(res.tweets.map(t => t.text).join("\n\n-\n\n"))
  239. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  240. else:
  241. page_nav = [
  242. dict(
  243. href=url_for('.get_tweet_html', tweet_id=tweets[0].conversation_id, view='thread'),
  244. label = 'author thread',
  245. order = 10
  246. ),
  247. dict(
  248. href = url_for('.get_tweet_html', tweet_id=tweets[0].conversation_id, view='conversation'),
  249. label = 'full convo',
  250. order = 20
  251. )
  252. ]
  253. tweet = tweets_response.data[0]
  254. user = list(filter(lambda u: u.id == tweet.author_id, tweets_response.includes.users))[0]
  255. source_url = f'https://twitter.com/{user.username}/status/{tweet_id}'
  256. title = f'Tweet by {user.name} at {tweet.created_at}'
  257. opengraph_info = dict(
  258. type = 'webpage', # threads might be article
  259. url = source_url,
  260. title = title,
  261. description = tweet.text,
  262. image = user.profile_image_url
  263. )
  264. if view == 'replies':
  265. tweet = tweets[0]
  266. if tweet.id == '1608510741941989378':
  267. unreplied = [
  268. UnrepliedSection(
  269. description = "Not clear what GS is still.",
  270. span = (40, 80)
  271. )
  272. ]
  273. tweet = replace(tweet,
  274. unreplied = unreplied
  275. )
  276. expand_parts = request.args.get('expand')
  277. if expand_parts:
  278. expand_parts = expand_parts.split(',')
  279. def reply_to_thread_item (fi):
  280. nonlocal expand_parts
  281. if fi.id == '1609714342211244038':
  282. print(f'reply_to_thread_item id={fi.id}')
  283. unreplied = [
  284. UnrepliedSection(
  285. description = "Is there proof of this claim?",
  286. span = (40, 80)
  287. )
  288. ]
  289. fi = replace(fi,
  290. unreplied = unreplied
  291. )
  292. children = None
  293. if expand_parts and len(expand_parts) and fi.id == expand_parts[0]:
  294. expand_parts = expand_parts[1:]
  295. print(f'getting expanded replied for tweet={fi.id}')
  296. expanded_replies_response = tweet_source.get_thread(fi.id,
  297. only_replies=True,
  298. return_dataclass=True)
  299. if expanded_replies_response.data:
  300. print('we got expanded responses data')
  301. children = list(map(lambda t: tweet_model_dc_vm(expanded_replies_response.includes, t, g.me), expanded_replies_response.data))
  302. children = list(map(reply_to_thread_item, children))
  303. return ThreadItem(feed_item=fi, children=children)
  304. children = list(map(reply_to_thread_item, tweets[1:]))
  305. root = ThreadItem(
  306. feed_item = tweet,
  307. children = children
  308. )
  309. return render_template('tweet-thread.html', user = user, root = root, query = query, page_nav=page_nav, skip_embed_replies=skip_embed_replies, opengraph_info=opengraph_info)
  310. else:
  311. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query, page_nav=page_nav, skip_embed_replies=skip_embed_replies, opengraph_info=opengraph_info)
  312. @twitter_app.route('/followers/<user_id>.html', methods=['GET'])
  313. def get_followers_html (user_id):
  314. if not g.twitter_user:
  315. return 'need to log in.', 403
  316. use_cache = request.args.get('use_cache')
  317. token = g.twitter_user['access_token']
  318. social_source = TwitterApiV2SocialGraph(token)
  319. if use_cache:
  320. print(f'using cache for user {user_id}: {use_cache}')
  321. with open(f'.data/cache/followers_{user_id}_{use_cache}.json', 'rt') as f:
  322. response_json = json.load(f)
  323. else:
  324. response_json = social_source.get_followers(user_id, return_dataclass=True)
  325. if not use_cache:
  326. ts = int(time.time() * 1000)
  327. print(f'followers cache for {user_id}: {ts}')
  328. with open(f'{DATA_DIR}/cache/followers_{user_id}_{ts}.json', 'wt') as f:
  329. json.dump(response_json, f, indent=2)
  330. #print(response_json)
  331. #run_script('on_user_seen', {'twitter_user': g.twitter_user, 'users': response_json})
  332. #followers = list(map(lambda f: f['id'], response_json.get('data')))
  333. followers = response_json.data
  334. followers = list(map(user_model_dc, followers))
  335. return render_template('following.html', users=followers)
  336. @twitter_app.route('/following/<user_id>.html', methods=['GET'])
  337. def get_following_html (user_id):
  338. if not g.twitter_user:
  339. return 'need to log in.', 403
  340. token = g.twitter_user['access_token']
  341. social_source = TwitterApiV2SocialGraph(token)
  342. response_json = social_source.get_following(user_id, return_dataclass=True)
  343. ts = int(time.time() * 1000)
  344. with open(f'{DATA_DIR}/cache/following_{user_id}_{ts}.json', 'wt') as f:
  345. f.write(json.dumps(response_json))
  346. #print(response_json)
  347. #run_script('on_user_seen', {'twitter_user': g.twitter_user, 'users': response_json})
  348. #following = list(map(lambda f: f['id'], response_json.get('data')))
  349. following = list(map(user_model_dc, response_json.data))
  350. return render_template('following.html', users=following)
  351. # ---------------------------------------------------------------------------------------------------------
  352. # ---------------------------------------------------------------------------------------------------------
  353. # HTMx partials
  354. # ---------------------------------------------------------------------------------------------------------
  355. # ---------------------------------------------------------------------------------------------------------
  356. def user_model (user):
  357. fsu = FeedServiceUser(
  358. id = user['id'],
  359. name = user['name'],
  360. username = user['username'],
  361. created_at = user['created_at'],
  362. description = '', # user['description'],
  363. preview_image_url = '', # user['profile_image_url'],
  364. url = url_for('.get_profile_html', user_id=user['id'])
  365. )
  366. return fsu
  367. def user_model_dc (user):
  368. fsu = FeedServiceUser(
  369. id = user.id,
  370. name = user.name,
  371. username = user.username,
  372. created_at = user.created_at,
  373. description = user.description,
  374. preview_image_url = user.profile_image_url,
  375. website = user.url,
  376. url = url_for('.get_profile_html', user_id=user.id),
  377. source_url = f'https://twitter.com/{user.username}'
  378. )
  379. return fsu
  380. def tweet_model_dc_vm (includes: TweetExpansions, tweet: Tweet, me, my_url_for=url_for, reply_depth=0) -> FeedItem:
  381. # retweeted_by, avi_icon_url, display_name, handle, created_at, text
  382. user = list(filter(lambda u: u.id == tweet.author_id, includes.users))[0]
  383. url = my_url_for('twitter_v2_facade.get_tweet_html', tweet_id=tweet.id, view='tweet')
  384. source_url = 'https://twitter.com/{}/status/{}'.format(user.username, tweet.id)
  385. avi_icon_url = user.profile_image_url
  386. retweet_of = None
  387. quoted = None
  388. replied_to = None
  389. if tweet.referenced_tweets:
  390. retweet_of = list(filter(lambda r: r.type == 'retweeted', tweet.referenced_tweets))
  391. quoted = list(filter(lambda r: r.type == 'quoted', tweet.referenced_tweets))
  392. replied_to = list(filter(lambda r: r.type == 'replied_to', tweet.referenced_tweets))
  393. actions = {
  394. 'view_replies': FeedItemAction('twitter_v2_facade.get_tweet_html', {'tweet_id': tweet.id, 'view': 'replies'}),
  395. 'view_thread': FeedItemAction('twitter_v2_facade.get_tweet_html', {'tweet_id': tweet.conversation_id, 'view': 'thread'}),
  396. 'view_conversation': FeedItemAction('twitter_v2_facade.get_tweet_html', {'tweet_id': tweet.conversation_id, 'view': 'conversation'}),
  397. }
  398. if g.get('twitter_user'):
  399. actions.update(
  400. bookmark = FeedItemAction('twitter_v2_facade.post_tweet_bookmark', {'tweet_id': tweet.id}),
  401. delete_bookmark = FeedItemAction('twitter_v2_facade.delete_tweet_bookmark', {'tweet_id': tweet.id}),
  402. retweet = FeedItemAction('twitter_v2_facade.post_tweet_retweet', {'tweet_id': tweet.id})
  403. )
  404. if g.twitter_live_enabled:
  405. actions.update(
  406. view_activity = FeedItemAction('twitter_v2_live_facade.get_tweet_activity_html', {'tweet_id': tweet.id})
  407. )
  408. t = FeedItem(
  409. id = tweet.id,
  410. text = tweet.text,
  411. created_at = tweet.created_at,
  412. author_is_verified = user.verified,
  413. url = url,
  414. conversation_id = tweet.conversation_id,
  415. avi_icon_url = avi_icon_url,
  416. display_name = user.name,
  417. handle = user.username,
  418. author_url = my_url_for('twitter_v2_facade.get_profile_html', user_id=user.id),
  419. author_id = user.id,
  420. source_url = source_url,
  421. source_author_url = 'https://twitter.com/{}'.format(user.username),
  422. #'is_edited': len(tweet['edit_history_tweet_ids']) > 1
  423. actions = actions,
  424. )
  425. if reply_depth:
  426. t = replace(t, reply_depth = reply_depth)
  427. # HACK we should not refer to the request directly...
  428. if request and request.args.get('marked_reply') == str(t.id):
  429. t = replace(t, is_marked = True)
  430. # This is where we should put "is_bookmark", "is_liked", "is_in_collection", etc...
  431. if tweet.entities:
  432. if tweet.entities.urls:
  433. urls = list(filter(lambda u: u.title and u.description, tweet.entities.urls))
  434. if len(urls):
  435. url = urls[0]
  436. card = Card(
  437. display_url = url.display_url.split('/')[0],
  438. source_url = url.unwound_url,
  439. content = url.description,
  440. title = url.title
  441. )
  442. t = replace(t, card = card)
  443. if tweet.public_metrics:
  444. public_metrics = PublicMetrics(
  445. reply_count = tweet.public_metrics.reply_count,
  446. quote_count = tweet.public_metrics.quote_count,
  447. retweet_count = tweet.public_metrics.retweet_count,
  448. like_count = tweet.public_metrics.like_count
  449. )
  450. t = replace(t, public_metrics = public_metrics)
  451. if tweet.non_public_metrics:
  452. non_public_metrics = NonPublicMetrics(
  453. impression_count = tweet.non_public_metrics.impression_count,
  454. user_profile_clicks = tweet.non_public_metrics.user_profile_clicks,
  455. url_link_clicks = tweet.non_public_metrics.url_link_clicks
  456. )
  457. t = replace(t, non_public_metrics = non_public_metrics)
  458. if retweet_of and len(retweet_of):
  459. print('found retweet_of')
  460. t = replace(t, retweeted_tweet_id = retweet_of[0].id)
  461. retweeted_tweet:Tweet = list(filter(lambda t: t.id == retweet_of[0].id, includes.tweets))[0]
  462. rt = tweet_model_dc_vm(includes, retweeted_tweet, me)
  463. t = replace(rt,
  464. retweeted_tweet_id = retweet_of[0].id,
  465. source_retweeted_by_url = 'https://twitter.com/{}'.format(user.username),
  466. retweeted_by = user.name,
  467. retweeted_by_url = url_for('.get_profile_html', user_id=user.id)
  468. )
  469. try:
  470. if tweet.attachments and tweet.attachments.media_keys and includes.media:
  471. media_keys = tweet.attachments.media_keys
  472. def first_media (mk):
  473. medias = list(filter(lambda m: m.media_key == mk, includes.media))
  474. if len(medias):
  475. return medias[0]
  476. return None
  477. media = list(filter(lambda m: m != None, map(first_media, media_keys)))
  478. photos = filter(lambda m: m.type == 'photo', media)
  479. videos = filter(lambda m: m.type == 'video', media)
  480. photo_media = map(lambda p: MediaItem(media_key = p.media_key, type = 'photo', preview_image_url = p.url + '?name=tiny&format=webp', url = p.url, width = p.width, height = p.height), photos)
  481. def video_to_mi (v):
  482. use_hls = False # mainly iOS
  483. max_bitrate = 100000000
  484. if use_hls:
  485. variants = list(filter(lambda var: var.content_type == 'application/x-mpegURL'))
  486. else:
  487. variants = list(filter(lambda var: var.content_type != 'application/x-mpegURL' and var.bit_rate <= max_bitrate, v.variants))
  488. variants.sort(key=lambda v: v.bit_rate, reverse=True)
  489. url = None
  490. content_type = None
  491. size = None
  492. if len(variants):
  493. if len(variants) > 1:
  494. print('multiple qualifying variants (using first):')
  495. print(variants)
  496. variant = variants[0]
  497. url = variant.url
  498. content_type = variant.content_type
  499. size = int(v.duration_ms / 1000 * variant.bit_rate)
  500. public_metrics = None
  501. if v.public_metrics and v.public_metrics.view_count:
  502. public_metrics = PublicMetrics(
  503. view_count = v.public_metrics.view_count
  504. )
  505. mi = MediaItem(
  506. media_key = v.media_key,
  507. type = 'video',
  508. preview_image_url = v.preview_image_url + '?name=tiny&format=webp',
  509. image_url = v.preview_image_url,
  510. width = v.width,
  511. height = v.height,
  512. url=url,
  513. content_type = content_type,
  514. duration_ms = v.duration_ms,
  515. size = size,
  516. public_metrics = public_metrics
  517. )
  518. return mi
  519. video_media = map(video_to_mi, videos)
  520. t = replace(t,
  521. photos = list(photo_media),
  522. videos = list(video_media)
  523. )
  524. elif tweet.attachments and tweet.attachments.media_keys and not includes.media:
  525. print('tweet had attachments and media keys, but no expansion media content was given')
  526. print(tweet.attachments.media_keys)
  527. except:
  528. # it seems like this comes when we have a retweeted tweet with media on it.
  529. print('exception adding attachments to tweet:')
  530. print(tweet)
  531. print('view tweet:')
  532. print(t)
  533. print('included media:')
  534. print(includes.media)
  535. raise 'exception adding attachments to tweet'
  536. try:
  537. if quoted and len(quoted):
  538. t = replace(t, quoted_tweet_id = quoted[0].id)
  539. quoted_tweets = list(filter(lambda t: t.id == quoted[0].id, includes.tweets))
  540. if len(quoted_tweets):
  541. t = replace(t, quoted_tweet = tweet_model_dc_vm(includes, quoted_tweets[0], me))
  542. except:
  543. raise 'error adding quoted tweet'
  544. try:
  545. if replied_to and len(replied_to) and includes.tweets:
  546. t = replace(t, replied_tweet_id = replied_to[0].id)
  547. if reply_depth < 1:
  548. replied_tweets = list(filter(lambda t: t.id == replied_to[0].id, includes.tweets))
  549. if len(replied_tweets):
  550. t = replace(t, replied_tweet = tweet_model_dc_vm(includes, replied_tweets[0], me, reply_depth=reply_depth + 1))
  551. else:
  552. print("No replied tweet found (t={}, rep={})".format(t.id, t.replied_tweet_id))
  553. except:
  554. raise 'error adding replied_to tweet'
  555. return t
  556. def tweet_paginated_timeline ():
  557. return
  558. @twitter_app.route('/data/tweets/user/<user_id>/media', methods=['GET'])
  559. def get_data_tweets_media (user_id):
  560. """
  561. Not used anywhere... trying an idea. tweet_model needs to be updated.
  562. """
  563. token = g.twitter_user['access_token']
  564. pagination_token = request.args.get('pagination_token')
  565. tweet_source = ApiV2TweetSource(token)
  566. response_json = tweet_source.get_media_tweets(author_id=user_id,
  567. has_images=True,
  568. is_reply=False,
  569. is_retweet=False,
  570. pagination_token = pagination_token)
  571. includes = response_json.get('includes')
  572. tweets = list(map(lambda t: tweet_model(includes, t, g.me), response_json['data']))
  573. next_token = response_json.get('meta').get('next_token')
  574. query = {}
  575. if next_token:
  576. query = {
  577. **query,
  578. 'next_data_url': url_for('.get_data_tweets_media', user_id=user_id, pagination_token=next_token)
  579. }
  580. if 'HX-Request' in request.headers:
  581. user = {
  582. 'id': user_id
  583. }
  584. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  585. else:
  586. response_body = json.dumps({
  587. 'data': tweets,
  588. 'query': query
  589. })
  590. return jsonify(response_body)
  591. # ---------------------------------------------------------------------------------------------------------
  592. # ---------------------------------------------------------------------------------------------------------
  593. # HTMx views
  594. # ---------------------------------------------------------------------------------------------------------
  595. # ---------------------------------------------------------------------------------------------------------
  596. @twitter_app.route('/latest.html', methods=['GET'])
  597. def get_timeline_home_html (variant = "reverse_chronological", pagination_token=None):
  598. if not g.twitter_user:
  599. return 'need to login. go to /login.html', 403
  600. user_id = g.twitter_user['id']
  601. token = g.twitter_user['access_token']
  602. if not pagination_token:
  603. pagination_token = request.args.get('pagination_token')
  604. output_format = request.args.get('format', 'html')
  605. tq = cleandict({
  606. 'pagination_token': pagination_token,
  607. 'since_id': request.args.get('since_id'),
  608. 'until_id': request.args.get('until_id'),
  609. 'end_time': request.args.get('end_time')
  610. })
  611. tweet_source = ApiV2TweetSource(token)
  612. response = tweet_source.get_home_timeline(user_id, **tq)
  613. #print(json.dumps(response_json, indent=2))
  614. includes = response.includes
  615. tweets = list(map(lambda t: tweet_model_dc_vm(includes, t, g.me), response.data))
  616. next_token = response.meta.next_token
  617. tq['pagination_token'] = next_token
  618. query = {
  619. **tq,
  620. 'format': output_format,
  621. 'me': g.me
  622. }
  623. if next_token:
  624. query = {
  625. **query,
  626. #'next_data_url': url_for('.get_data_timeline_home', variant=variant, pagination_token=next_token),
  627. 'next_data_url': url_for('.get_timeline_home_html', **tq),
  628. 'next_page_url': url_for('.get_timeline_home_html', **tq)
  629. }
  630. user = {
  631. 'id': user_id
  632. }
  633. if output_format == 'feed.json':
  634. return jsonify(cleandict({
  635. 'data': tweets,
  636. 'query': query
  637. }))
  638. elif 'HX-Request' in request.headers:
  639. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query, show_thread_controls=True)
  640. else:
  641. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query, show_thread_controls=True)
  642. @twitter_app.route('/bookmarks.html', methods=['GET'])
  643. def get_bookmarks_html ():
  644. user_id = g.twitter_user['id']
  645. token = g.twitter_user['access_token']
  646. pagination_token = request.args.get('pagination_token')
  647. tweet_source = ApiV2TweetSource(token)
  648. response_json = tweet_source.get_bookmarks(user_id,
  649. pagination_token = pagination_token, return_dataclass=True)
  650. #print(response_json)
  651. includes = response_json.includes
  652. tweets = list(map(lambda t: tweet_model_dc_vm(includes, t, g.me), response_json.data))
  653. next_token = response_json.meta.next_token
  654. query = {}
  655. if next_token:
  656. query = {
  657. **query,
  658. 'next_data_url': url_for('.get_bookmarks_html', user_id=user_id, pagination_token=next_token),
  659. 'next_page_url': url_for('.get_bookmarks_html', user_id=user_id, pagination_token=next_token)
  660. }
  661. user = {
  662. 'id': user_id
  663. }
  664. if 'HX-Request' in request.headers:
  665. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query)
  666. else:
  667. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query)
  668. @twitter_app.route('/profile/<user_id>.html', methods=['GET'])
  669. def get_profile_html (user_id):
  670. if g.twitter_user:
  671. token = g.twitter_user['access_token']
  672. # issue: retweets don't come back if we request non_public_metrics
  673. is_me = False and user_id == g.twitter_user['id']
  674. else:
  675. token = os.environ.get('BEARER_TOKEN')
  676. is_me = False
  677. output_format = request.args.get('format', 'html')
  678. pagination_token = request.args.get('pagination_token')
  679. exclude_replies = request.args.get('exclude_replies', '0')
  680. exclude_retweets = request.args.get('exclude_retweets', '0')
  681. tweet_source = ApiV2TweetSource(token)
  682. response_json = tweet_source.get_user_timeline(user_id,
  683. exclude_replies = exclude_replies == '1',
  684. exclude_retweets = exclude_retweets == '1',
  685. pagination_token = pagination_token,
  686. non_public_metrics = is_me,
  687. return_dataclass=True)
  688. if not response_json:
  689. print('no response_json')
  690. if response_json.meta.result_count == 0:
  691. print('no results')
  692. if not response_json.includes:
  693. print(response_json)
  694. print('no response_json.includes')
  695. if response_json.errors:
  696. print('profile get_user_timeline errors:')
  697. print(response_json.errors)
  698. ts = int(time.time() * 1000)
  699. with open(f'{DATA_DIR}/cache/tl_{user_id}_{ts}_{pagination_token}.json', 'wt') as f:
  700. f.write(json.dumps(cleandict(asdict(response_json))))
  701. if response_json.data:
  702. tweets = list(map(lambda t: tweet_model_dc_vm(response_json.includes, t, g.me), response_json.data))
  703. else:
  704. tweets = []
  705. next_token = response_json.meta.next_token
  706. query = cleandict({
  707. 'pagination_token': pagination_token,
  708. 'exclude_replies': exclude_replies,
  709. 'exclude_retweets': exclude_retweets,
  710. 'format': output_format
  711. })
  712. if next_token:
  713. query = {
  714. **query,
  715. 'next_data_url': url_for('.get_profile_html', user_id=user_id, pagination_token=next_token, exclude_replies=exclude_replies, exclude_retweets=exclude_retweets),
  716. 'next_page_url': url_for('.get_profile_html', user_id=user_id , pagination_token=next_token, exclude_replies=exclude_replies, exclude_retweets=exclude_retweets)
  717. }
  718. if output_format == 'feed.json':
  719. return jsonify(cleandict({
  720. 'data': tweets,
  721. 'query': query
  722. }))
  723. elif 'HX-Request' in request.headers:
  724. profile_user = {
  725. 'id': user_id
  726. }
  727. return render_template('partial/tweets-timeline.html', user = profile_user, tweets = tweets, query = query)
  728. else:
  729. # FIXME the user is probably present in the tweet expansions info.
  730. social_graph = TwitterApiV2SocialGraph(token)
  731. users_response = social_graph.get_user(user_id)
  732. print(users_response)
  733. user = users_response['data'][0]
  734. title = f'{user["name"]} ({user["username"]})'
  735. # FIXME official Twitter or owner's instance?
  736. source_url = f'https://www.twitter.com/{user["username"]}'
  737. opengraph_info = dict(
  738. type = 'webpage', # threads might be article
  739. url = source_url,
  740. title = title,
  741. description = user['description'],
  742. image = user['profile_image_url']
  743. )
  744. page_nav = [
  745. dict(
  746. href = url_for('twitter_v2_facade.get_profile_html', user_id=user['id']),
  747. label = 'Timeline',
  748. order = 10,
  749. ),
  750. dict (
  751. href = url_for('twitter_v2_facade.get_following_html', user_id=user['id']),
  752. label = 'Following',
  753. order = 40,
  754. ),
  755. dict (
  756. href = url_for('twitter_v2_facade.get_followers_html', user_id=user['id']),
  757. label = 'Followers',
  758. order = 50,
  759. )
  760. ]
  761. if not g.twitter_user:
  762. for uid, acct in session.items():
  763. if uid.startswith('twitter:'):
  764. page_nav += [
  765. dict(
  766. href = url_for('twitter_v2_facade.get_profile_html', user_id=user_id, me=uid),
  767. label = f'View as {acct["id"]}',
  768. order = 1000,
  769. )
  770. ]
  771. if g.twitter_live_enabled:
  772. page_nav += [
  773. dict(
  774. href = url_for('twitter_v2_live_facade.get_likes_html', user_id=user['id']),
  775. label = 'Likes',
  776. order = 20,
  777. ),
  778. dict (
  779. href = url_for('twitter_v2_live_facade.get_mentions_html', user_id=user['id']),
  780. label = 'Mentions',
  781. order = 30,
  782. ),
  783. dict (
  784. href = url_for('twitter_v2_live_facade.get_user_activity_html', user_id=user['id']),
  785. label = 'Activity',
  786. order = 60,
  787. )
  788. ]
  789. top8 = get_top8(user_id)
  790. brand_info = {}
  791. if g.twitter_live_enabled:
  792. brand = find_brand_by_account(f'twitter:{user_id}')
  793. if brand:
  794. page_nav += [
  795. dict(
  796. href = url_for('brands.get_brand_html', brand_id=brand['id']),
  797. label = 'Brand Page',
  798. order = 5000,
  799. )
  800. ]
  801. brand_info = fetch_brand_info(brand)
  802. brand_info.update({'brand': brand, 'twitter': None})
  803. return render_template('user-profile.html', user = user, tweets = tweets, query = query, opengraph_info=opengraph_info, page_nav = page_nav, top8=top8, **brand_info)
  804. @twitter_app.route('/users.html', methods=['GET'])
  805. def get_users ():
  806. ids = request.args.get('ids')
  807. if not ids:
  808. return 'supply ids=', 400
  809. token = g.twitter_user['access_token']
  810. tweet_source = TwitterApiV2SocialGraph(token)
  811. response_json = tweet_source.get_users(ids)
  812. ts = int(time.time() * 1000)
  813. with open(f'{DATA_DIR}/cache/users_{ts}.json', 'wt') as f:
  814. f.write(json.dumps(response_json))
  815. #print(response_json)
  816. #run_script('on_user_seen', {'twitter_user': g.twitter_user, 'users': response_json})
  817. #following = list(map(lambda f: f['id'], response_json.get('data')))
  818. users = response_json.get('data')
  819. return render_template('following.html', users=users)
  820. @twitter_app.route('/media/upload', methods=['POST'])
  821. def post_media_upload ():
  822. token = g.twitter_user['access_token']
  823. form = {
  824. 'media_category': 'tweet_image'
  825. }
  826. headers = {
  827. 'Authorization': 'Bearer {}'.format(token)
  828. }
  829. url = 'http://localhost:5004/twitter/fake-twitter/media/upload'
  830. #url = 'https://upload.twitter.com/1.1/media/upload.json' # .json
  831. upload_media = {}
  832. for e in request.files.items():
  833. media_name = e[0]
  834. f = e[1]
  835. print('.')
  836. files = {'media': [secure_filename(f.filename), BufferedReader(f), f.content_type]}
  837. response = requests.post(url, files=files, data=form, headers=headers)
  838. print(response.status_code)
  839. print(response.text)
  840. response_json = json.loads(response.text)
  841. upload_media[media_name] = response_json
  842. return jsonify({'upload_media': upload_media})
  843. @twitter_app.route('/fake-twitter/media/upload', methods=['POST'])
  844. def post_media_upload2 ():
  845. print(request.content_type)
  846. f = request.files.get('media')
  847. f.seek(0,2)
  848. media_size = f.tell()
  849. media = {
  850. #'_auth': request.headers.get('Authorization'),
  851. 'media_key': '3_{}'.format(secure_filename(f.filename)),
  852. 'media_id': secure_filename(f.filename),
  853. 'size': media_size,
  854. 'expires_after_secs': 86400,
  855. 'image': {
  856. 'image_type': f.content_type,
  857. 'w': 1,
  858. 'h': 1
  859. }
  860. }
  861. return jsonify(media)
  862. def get_nav_items ():
  863. nav_items = [
  864. ]
  865. twitter_user = g.get('twitter_user')
  866. me = g.get('me')
  867. if twitter_user:
  868. nav_items += [
  869. dict(
  870. href = url_for('twitter_v2_facade.get_timeline_home_html'),
  871. label = 'Latest Tweets',
  872. order = 0
  873. ),
  874. dict (
  875. href = url_for('twitter_v2_facade.get_bookmarks_html'),
  876. label = 'Bookmarks',
  877. order = 100
  878. ),
  879. dict (
  880. href = url_for('twitter_v2_facade.get_profile_html', user_id=twitter_user['id']),
  881. label = 'My Profile',
  882. order = 200
  883. ),
  884. dict (
  885. href = url_for('twitter_v2_facade.oauth2_login.get_logout_html'),
  886. label = f'Logout ({me})',
  887. order = 1000
  888. )
  889. ]
  890. if g.get('twitter_live_enabled'):
  891. nav_items += [
  892. dict (
  893. href = url_for('twitter_v2_live_facade.get_conversations_html'),
  894. label = 'DMs',
  895. order = 10
  896. ),
  897. dict (
  898. href = url_for('twitter_v2_live_facade.get_mentions_html', user_id=twitter_user['id']),
  899. label = 'Mentions',
  900. order = 20
  901. )
  902. ]
  903. return nav_items
  904. @twitter_app.before_request
  905. def add_module_nav_items_to_template_context ():
  906. g.module_nav = get_nav_items()
  907. def get_top8 (user_id):
  908. if user_id != '14520320':
  909. return
  910. return [
  911. dict(
  912. id='14520320'
  913. ),
  914. dict(
  915. id='14520320'
  916. ),
  917. dict(
  918. id='14520320'
  919. ),
  920. dict(
  921. id='14520320'
  922. ),
  923. dict(
  924. id='14520320'
  925. ),
  926. dict(
  927. id='14520320'
  928. ),
  929. dict(
  930. id='14520320'
  931. ),
  932. dict(
  933. id='14520320'
  934. ),
  935. ]