twitter_v2_facade.py 46 KB

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