content_source.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835
  1. """
  2. This translates from the Tweet Source and Twitter v2 types
  3. Into ViewModel types such as FeedItem
  4. And the rest of the Taxonomy.
  5. """
  6. from dataclasses import asdict
  7. from typing import List, Optional
  8. import os
  9. from flask import session, g, request
  10. import time
  11. from datetime import datetime, timezone
  12. import json
  13. import sqlite3
  14. from twitter_v2.api import ApiV2TweetSource, TwitterApiV2SocialGraph, ApiV2ConversationSource
  15. import hogumathi_app.view_model as h_vm
  16. from hogumathi_app.view_model import CollectionPage, cleandict
  17. from hogumathi_app.content_system import register_content_source, get_content, register_hook
  18. from .view_model import tweet_model_dc_vm, user_model_dc
  19. DATA_DIR='.data'
  20. CACHE_PATH = f'{DATA_DIR}/twitter_v2_cache.db'
  21. def init_cache_db ():
  22. db = sqlite3.connect(CACHE_PATH)
  23. cur = db.cursor()
  24. table_exists = cur.execute(f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='tweet'").fetchone()[0]
  25. if not table_exists:
  26. cur.execute("""
  27. create table query (
  28. created_at timestamp,
  29. user_id text,
  30. last_accessed_at timestamp,
  31. next_token text,
  32. query_type text,
  33. auth_user_id text
  34. )
  35. """)
  36. cur.execute("""
  37. create table tweet (
  38. id text,
  39. accessed_at timestamp,
  40. query_id int,
  41. data text,
  42. unique(id, query_id)
  43. )
  44. """)
  45. cur.execute("""
  46. create table user (
  47. id text,
  48. accessed_at timestamp,
  49. query_id int,
  50. data text,
  51. unique(id, query_id)
  52. )
  53. """)
  54. cur.execute("""
  55. create table medium (
  56. id text,
  57. accessed_at timestamp,
  58. query_id int,
  59. data text,
  60. unique(id, query_id)
  61. )
  62. """)
  63. cur.connection.commit()
  64. print(f'--- created {CACHE_PATH}')
  65. cur.close()
  66. def cache_tweets_response (response_tweets, query_type, auth_user_id, user_id = None, pagination_token=None, ts=None):
  67. """
  68. In bookmarks I observed that the same next_token is returned even with distinct new queries started.
  69. So in the case of abandoned paginations, we can end up with duplicate next_token records,
  70. meaning we could update the wrong query_id, having downstream timestamp effects.
  71. """
  72. includes = response_tweets.includes
  73. tweets = response_tweets.data or []
  74. users = includes and includes.users or []
  75. media = includes and includes.media or []
  76. next_token = response_tweets.meta.next_token
  77. db = sqlite3.connect(CACHE_PATH)
  78. cur = db.cursor()
  79. # SQLite is naive by default, so make sure this is UTC.
  80. now = datetime.now(timezone.utc)
  81. if ts:
  82. now = ts
  83. if not pagination_token:
  84. cur.execute("""
  85. insert into query (
  86. created_at,
  87. last_accessed_at,
  88. user_id,
  89. next_token,
  90. query_type,
  91. auth_user_id
  92. )
  93. values (
  94. ?,?,?,?,?,?
  95. )
  96. """,
  97. [now, now, user_id, next_token, query_type, auth_user_id]
  98. )
  99. query_id = cur.lastrowid
  100. else:
  101. query_id = cur.execute("""
  102. select rowid from query
  103. where next_token = :next_token
  104. """,
  105. {
  106. 'next_token': pagination_token
  107. }).fetchone()[0]
  108. cur.execute("""
  109. update query set
  110. last_accessed_at = :last_accessed_at,
  111. next_token = :next_token
  112. where rowid = :query_id
  113. """,
  114. {
  115. 'last_accessed_at': now,
  116. 'next_token': next_token,
  117. 'query_id': query_id
  118. })
  119. for tweet in tweets:
  120. tweet_json = json.dumps(cleandict(asdict(tweet)))
  121. cur.execute("""
  122. insert or ignore into tweet (
  123. id,
  124. accessed_at,
  125. query_id,
  126. data
  127. )
  128. values (
  129. ?,?,?,?
  130. )
  131. """,
  132. [ tweet.id, now, query_id, tweet_json ]
  133. )
  134. for user in users:
  135. user_json = json.dumps(cleandict(asdict(user)))
  136. cur.execute("""
  137. insert or ignore into user (
  138. id,
  139. accessed_at,
  140. query_id,
  141. data
  142. )
  143. values (
  144. ?,?,?,?
  145. )
  146. """,
  147. [ user.id, now, query_id, user_json ]
  148. )
  149. for medium in media:
  150. medium_json = json.dumps(cleandict(asdict(medium)))
  151. cur.execute("""
  152. insert or ignore into medium (
  153. id,
  154. accessed_at,
  155. query_id,
  156. data
  157. )
  158. values (
  159. ?,?,?,?
  160. )
  161. """,
  162. [ medium.media_key, now, query_id, medium_json ]
  163. )
  164. cur.connection.commit()
  165. cur.close()
  166. def cache_users_response (response_users, query_type, auth_user_id, user_id = None, pagination_token=None, ts=None):
  167. users = response_users.data or []
  168. next_token = response_users.meta and response_users.meta.get('next_token')
  169. db = sqlite3.connect(CACHE_PATH)
  170. cur = db.cursor()
  171. # SQLite is naive by default, so make sure this is UTC.
  172. now = None
  173. if ts:
  174. now = ts
  175. if not pagination_token:
  176. cur.execute("""
  177. insert into query (
  178. created_at,
  179. last_accessed_at,
  180. user_id,
  181. next_token,
  182. query_type,
  183. auth_user_id
  184. )
  185. values (
  186. ?,?,?,?,?,?
  187. )
  188. """,
  189. [now, now, user_id, next_token, query_type, auth_user_id]
  190. )
  191. query_id = cur.lastrowid
  192. else:
  193. query_id = cur.execute("""
  194. select rowid from query
  195. where next_token = :next_token
  196. """,
  197. {
  198. 'next_token': pagination_token
  199. }).fetchone()[0]
  200. cur.execute("""
  201. update query set
  202. last_accessed_at = :last_accessed_at,
  203. next_token = :next_token
  204. where rowid = :query_id
  205. """,
  206. {
  207. 'last_accessed_at': now,
  208. 'next_token': next_token,
  209. 'query_id': query_id
  210. })
  211. for user in users:
  212. user_json = json.dumps(cleandict(asdict(user)))
  213. cur.execute("""
  214. insert or ignore into user (
  215. id,
  216. accessed_at,
  217. query_id,
  218. data
  219. )
  220. values (
  221. ?,?,?,?
  222. )
  223. """,
  224. [ user.id, now, query_id, user_json ]
  225. )
  226. cur.connection.commit()
  227. cur.close()
  228. def get_cached_query (query_type, auth_user_id, user_id=None):
  229. sql = """
  230. select * from query
  231. where
  232. (auth_user_id in ('14520320') or auth_user_id is null)
  233. and query_type = 'bookmarks'
  234. """
  235. results = []
  236. next_token = None
  237. return results, next_token
  238. def get_object_over_time (obj_type, obj_id, auth_user_id):
  239. cur = None
  240. results = cur.execute(f"""
  241. --select id, count(*) c from tweet group by id having c > 1
  242. select t.*
  243. from {obj_type} t, query q
  244. where
  245. t.id = :obj_id
  246. and q.rowid = t.query_id
  247. and (q.auth_user_id in (:auth_user_id) or q.auth_user_id is null)
  248. """,
  249. {
  250. 'obj_id': obj_id,
  251. 'auth_user_id': auth_user_id
  252. })
  253. results = []
  254. next_token = None
  255. return results, next_token
  256. def get_tweet_item (tweet_id, me=None):
  257. if me:
  258. twitter_user = session.get(me)
  259. token = twitter_user['access_token']
  260. else:
  261. token = os.environ.get('BEARER_TOKEN')
  262. tweet_source = ApiV2TweetSource(token)
  263. tweets_response = tweet_source.get_tweet(tweet_id, return_dataclass=True)
  264. #print(response_json)
  265. if tweets_response.errors:
  266. # types:
  267. # https://api.twitter.com/2/problems/not-authorized-for-resource (blocked or suspended)
  268. # https://api.twitter.com/2/problems/resource-not-found (deleted)
  269. #print(response_json.get('errors'))
  270. for err in tweets_response.errors:
  271. if not 'type' in err:
  272. print('unknown error type: ' + str(err))
  273. elif err['type'] == 'https://api.twitter.com/2/problems/not-authorized-for-resource':
  274. print('blocked or suspended tweet: ' + err['value'])
  275. elif err['type'] == 'https://api.twitter.com/2/problems/resource-not-found':
  276. print('deleted tweet: ' + err['value'])
  277. else:
  278. print('unknown error')
  279. print(json.dumps(err, indent=2))
  280. includes = tweets_response.includes
  281. tweets = list(map(lambda t: tweet_model_dc_vm(includes, t, me), tweets_response.data))
  282. collection_page = CollectionPage(
  283. id = tweet_id,
  284. items = tweets,
  285. next_token = None # Fixme
  286. )
  287. return collection_page
  288. def tweet_embed_template (tweet_id):
  289. features = '{"tfw_timeline_list":{"bucket":[],"version":null},"tfw_follower_count_sunset":{"bucket":true,"version":null},"tfw_tweet_edit_backend":{"bucket":"on","version":null},"tfw_refsrc_session":{"bucket":"on","version":null},"tfw_mixed_media_15897":{"bucket":"treatment","version":null},"tfw_experiments_cookie_expiration":{"bucket":1209600,"version":null},"tfw_duplicate_scribes_to_settings":{"bucket":"on","version":null},"tfw_video_hls_dynamic_manifests_15082":{"bucket":"true_bitrate","version":null},"tfw_legacy_timeline_sunset":{"bucket":true,"version":null},"tfw_tweet_edit_frontend":{"bucket":"on","version":null}}'
  290. # base64 + encode URI component
  291. features_encoded = 'eyJ0ZndfdGltZWxpbmVfbGlzdCI6eyJidWNrZXQiOltdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2ZvbGxvd2VyX2NvdW50X3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9iYWNrZW5kIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19yZWZzcmNfc2Vzc2lvbiI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfbWl4ZWRfbWVkaWFfMTU4OTciOnsiYnVja2V0IjoidHJlYXRtZW50IiwidmVyc2lvbiI6bnVsbH0sInRmd19leHBlcmltZW50c19jb29raWVfZXhwaXJhdGlvbiI6eyJidWNrZXQiOjEyMDk2MDAsInZlcnNpb24iOm51bGx9LCJ0ZndfZHVwbGljYXRlX3NjcmliZXNfdG9fc2V0dGluZ3MiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3ZpZGVvX2hsc19keW5hbWljX21hbmlmZXN0c18xNTA4MiI6eyJidWNrZXQiOiJ0cnVlX2JpdHJhdGUiLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2xlZ2FjeV90aW1lbGluZV9zdW5zZXQiOnsiYnVja2V0Ijp0cnVlLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3R3ZWV0X2VkaXRfZnJvbnRlbmQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfX0%3D'
  292. origin = f"http%3A%2F%2Flocalhost%3A5004%2Ftwitter%2Ftweet2%2F{tweet_id}.html"
  293. width = 550
  294. height = 755
  295. theme = "dark" # or light
  296. hide_card = "false"
  297. hide_thread = "false"
  298. src = f"https://platform.twitter.com/embed/Tweet.html?dnt=true&features={features_encoded}&origin={origin}&frame=false&hideCard={hide_card}&hideThread={hide_thread}&id={tweet_id}&lang=en&theme=dark&width={width}px"
  299. html = f"""
  300. <iframe src="{src}" data-tweet-id="{tweet_id}"
  301. scrolling="no" frameborder="0" allowtransparency="true" allowfullscreen="true" class="" style="position: static; visibility: visible; width: {width}px; height: {height}px; display: block; flex-grow: 1;" title="Twitter Tweet"
  302. ></iframe>
  303. """
  304. return html
  305. # https://developer.twitter.com/en/docs/twitter-for-websites/embedded-tweets/overview
  306. def get_tweet_embed (tweet_id):
  307. html = tweet_embed_template(tweet_id)
  308. post = h_vm.FeedItem(
  309. id = tweet_id,
  310. created_at = 'some time',
  311. display_name = 'Twitter User',
  312. handle = 'tweetuser',
  313. html = html
  314. )
  315. return post
  316. def get_bookmarks_feed (user_id, pagination_token=None, max_results=10, me=None):
  317. if not me:
  318. me = g.get('me') or request.args.get('me')
  319. print(f'get_bookmarks_feed. me={me}')
  320. twitter_user = session.get( me )
  321. if not twitter_user:
  322. return None
  323. token = twitter_user['access_token']
  324. tweet_source = ApiV2TweetSource(token)
  325. response_tweets = tweet_source.get_bookmarks(user_id,
  326. pagination_token = pagination_token,
  327. return_dataclass=True,
  328. max_results=max_results
  329. )
  330. #print(response_json)
  331. cache_tweets_response(response_tweets, 'bookmarks', user_id, user_id=user_id, pagination_token=pagination_token)
  332. includes = response_tweets.includes
  333. tweets = list(map(lambda t: tweet_model_dc_vm(includes, t, me), response_tweets.data))
  334. next_token = response_tweets.meta.next_token
  335. query = {}
  336. if next_token:
  337. query = {
  338. **query,
  339. }
  340. user = {
  341. 'id': user_id
  342. }
  343. ts = int(time.time() * 1000)
  344. with open(f'{DATA_DIR}/cache/bookmarks_{user_id}_{ts}_{pagination_token}.json', 'wt') as f:
  345. f.write(json.dumps(cleandict(asdict(response_tweets))))
  346. collection_page = CollectionPage(
  347. id = user_id, # FIXME this should perhaps be the unresolved id
  348. items = tweets,
  349. next_token = next_token
  350. )
  351. return collection_page
  352. def get_user_feed (user_id, me=None, **twitter_kwargs):
  353. if not me and 'me' in g:
  354. me = g.me
  355. if 'twitter_user' in g and g.twitter_user:
  356. token = g.twitter_user['access_token']
  357. # issue: retweets don't come back if we request non_public_metrics
  358. is_me = False and user_id == g.twitter_user['id']
  359. auth_user_id = g.twitter_user['id']
  360. else:
  361. token = os.environ.get('BEARER_TOKEN')
  362. is_me = False
  363. auth_user_id = None
  364. tweet_source = ApiV2TweetSource(token)
  365. tweets_response = tweet_source.get_user_timeline(user_id,
  366. return_dataclass=True,
  367. **twitter_kwargs)
  368. tweets = None
  369. if not tweets_response:
  370. print('no response_json')
  371. if tweets_response.meta and tweets_response.meta.result_count == 0:
  372. print('no results')
  373. print(tweets_response)
  374. if not tweets_response.includes:
  375. print(tweets_response)
  376. print('no tweets_response.includes')
  377. if tweets_response.errors:
  378. print('profile get_user_timeline errors:')
  379. print(tweets_response.errors)
  380. pagination_token=twitter_kwargs.get('pagination_token')
  381. cache_tweets_response(tweets_response, 'user_feed', auth_user_id, user_id=user_id, pagination_token=pagination_token)
  382. ts = int(time.time() * 1000)
  383. with open(f'{DATA_DIR}/cache/tl_{user_id}_{ts}_{pagination_token}.json', 'wt') as f:
  384. f.write(json.dumps(cleandict(asdict(tweets_response))))
  385. if tweets_response.data:
  386. tweets = list(map(lambda t: tweet_model_dc_vm(tweets_response.includes, t, me), tweets_response.data))
  387. next_token = tweets_response.meta.next_token
  388. collection_page = CollectionPage(
  389. id = user_id,
  390. items = tweets,
  391. next_token = next_token
  392. )
  393. return collection_page
  394. def get_tweets_collection (content_ids, pagination_token=None, max_results=None):
  395. """
  396. We might be able to have a generalizer in the content system as well...
  397. If a source exposes a get many interface then use it. We want to avoid many singular fetches.
  398. """
  399. return []
  400. def get_user (user_id, me=None) -> Optional[h_vm.FeedServiceUser]:
  401. users = get_users([user_id], me=me)
  402. if users:
  403. return users[0]
  404. def get_users (content_ids, me=None, pagination_token=None) -> Optional[List[h_vm.FeedServiceUser]]:
  405. """
  406. """
  407. if me:
  408. twitter_user = session.get(me)
  409. token = twitter_user['access_token']
  410. auth_user_id = twitter_user['id']
  411. else:
  412. token = os.environ.get('BEARER_TOKEN')
  413. auth_user_id = None
  414. social_graph = TwitterApiV2SocialGraph(token)
  415. users_response = social_graph.get_users(content_ids, return_dataclass=True)
  416. if not len(users_response.data):
  417. return
  418. cache_users_response(users_response, f'users', auth_user_id, pagination_token=pagination_token)
  419. users = list(map(user_model_dc, users_response.data))
  420. return users
  421. def get_home_feed (user_id, me, **query_kwargs):
  422. twitter_user = session.get(me)
  423. token = twitter_user['access_token']
  424. auth_user_id = twitter_user['id']
  425. tweet_source = ApiV2TweetSource(token)
  426. response = tweet_source.get_home_timeline(user_id, **query_kwargs)
  427. #print(json.dumps(response_json, indent=2))
  428. pagination_token = query_kwargs.get('pagination_token')
  429. cache_tweets_response(response, 'home_feed', auth_user_id, user_id=user_id, pagination_token=pagination_token)
  430. includes = response.includes
  431. tweets = list(map(lambda t: tweet_model_dc_vm(includes, t, me), response.data))
  432. next_token = response.meta.next_token
  433. collection_page = CollectionPage(
  434. id = user_id,
  435. items = tweets,
  436. next_token = next_token
  437. )
  438. return collection_page
  439. def get_author_threads (user_id):
  440. """
  441. Placeholder implementation where we can manually add threads to a collection,
  442. but ultimately we will query a local Tweet DB that gets populated through various means.
  443. Once we store Tweets we can easily query this.
  444. We can filter by author_id,conversation_id order by in_reply_to_tweet_id,id
  445. """
  446. return get_content(f'collection:twitter.threads_{user_id}')
  447. def get_tweet_replies (conversation_id, in_reply_to_id=None, pagination_token=None, max_results=None, author_id=None):
  448. """
  449. New function, not used yet
  450. """
  451. tweet_source = ApiV2TweetSource(token)
  452. auth_user_id = None
  453. only_replies = view == 'replies'
  454. tweets = []
  455. skip_embed_replies = False
  456. if view == 'replies':
  457. replies_response = tweet_source.get_thread(in_reply_to_id,
  458. only_replies=True,
  459. pagination_token = pagination_token,
  460. return_dataclass=True)
  461. elif view == 'thread':
  462. skip_embed_replies = True
  463. replies_response = tweet_source.get_thread(conversation_id,
  464. only_replies=False,
  465. author_id=author_id,
  466. pagination_token = pagination_token,
  467. return_dataclass=True)
  468. elif view == 'conversation':
  469. replies_response = tweet_source.get_thread(conversation_id,
  470. only_replies=False,
  471. pagination_token = pagination_token,
  472. return_dataclass=True)
  473. elif view == 'tweet':
  474. replies_response = None
  475. next_token = None
  476. #print("conversation meta:")
  477. #print(json.dumps(tweets_response.get('meta'), indent=2))
  478. if replies_response and replies_response.meta and replies_response.meta.result_count:
  479. cache_tweets_response(replies_response, 'tweet_replies', auth_user_id, user_id=user_id, pagination_token=pagination_token)
  480. includes = replies_response.includes
  481. tweets = list(map(lambda t: tweet_model_dc_vm(includes, t, g.me, expand_path=request.args.get('expand'), reply_depth=1), replies_response.data)) + tweets
  482. next_token = replies_response.meta.next_token
  483. # this method is OK except it doesn't work if there are no replies.
  484. #tweets.append(tweet_model(includes, list(filter(lambda t: t['id'] == tweet_id, includes.get('tweets')))[0], me))
  485. #related_tweets = [] # derived from includes
  486. tweets.reverse()
  487. query = {}
  488. if next_token:
  489. query = {
  490. **query,
  491. # FIXME only_replies
  492. 'next_data_url': url_for('.get_tweet2_html', tweet_id=tweet_id, pagination_token=next_token, only_replies = '1' if only_replies else '0', author_id = tweets[0].author_id),
  493. 'next_page_url': url_for('.get_tweet2_html', tweet_id=tweet_id, view=view, pagination_token=next_token)
  494. }
  495. user = {
  496. }
  497. if view == 'replies':
  498. tweet = tweets[0]
  499. if tweet.id == '1608510741941989378':
  500. unreplied = [
  501. UnrepliedSection(
  502. description = "Not clear what GS is still.",
  503. span = (40, 80)
  504. )
  505. ]
  506. tweet = replace(tweet,
  507. unreplied = unreplied
  508. )
  509. expand_parts = request.args.get('expand')
  510. if expand_parts:
  511. expand_parts = expand_parts.split(',')
  512. def reply_to_thread_item (fi):
  513. nonlocal expand_parts
  514. if fi.id == '1609714342211244038':
  515. print(f'reply_to_thread_item id={fi.id}')
  516. unreplied = [
  517. UnrepliedSection(
  518. description = "Is there proof of this claim?",
  519. span = (40, 80)
  520. )
  521. ]
  522. fi = replace(fi,
  523. unreplied = unreplied
  524. )
  525. children = None
  526. if expand_parts and len(expand_parts) and fi.id == expand_parts[0]:
  527. expand_parts = expand_parts[1:]
  528. print(f'getting expanded replied for tweet={fi.id}')
  529. expanded_replies_response = tweet_source.get_thread(fi.id,
  530. only_replies=True,
  531. return_dataclass=True)
  532. if expanded_replies_response.data:
  533. print('we got expanded responses data')
  534. children = list(map(lambda t: tweet_model_dc_vm(expanded_replies_response.includes, t, g.me, expand_path=request.args.get('expand'), reply_depth=1), expanded_replies_response.data))
  535. children = list(map(reply_to_thread_item, children))
  536. return ThreadItem(feed_item=fi, children=children)
  537. children = list(map(reply_to_thread_item, tweets[1:]))
  538. root = ThreadItem(
  539. feed_item = tweet,
  540. children = children
  541. )
  542. 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)
  543. else:
  544. return render_template(f'tweet-collection{theme_variant}.html', user = user, tweets = tweets, query = query, page_nav=page_nav, skip_embed_replies=skip_embed_replies, opengraph_info=opengraph_info)
  545. def get_following_users (user_id, me=None, max_results=1000, pagination_token=None):
  546. if me:
  547. twitter_user = session.get(me)
  548. token = twitter_user['access_token']
  549. auth_user_id = twitter_user['id']
  550. else:
  551. token = os.environ.get('BEARER_TOKEN')
  552. auth_user_id = None
  553. social_source = TwitterApiV2SocialGraph(token)
  554. following_resp = social_source.get_following(user_id,
  555. max_results=max_results, pagination_token=pagination_token, return_dataclass=True)
  556. cache_users_response(following_resp, 'following', auth_user_id, user_id = user_id, pagination_token=pagination_token)
  557. ts = int(time.time() * 1000)
  558. with open(f'{DATA_DIR}/cache/following_{user_id}_{ts}_{pagination_token}.json', 'wt') as f:
  559. f.write(json.dumps(cleandict(asdict(following_resp))))
  560. #print(following_resp)
  561. #run_script('on_user_seen', {'twitter_user': g.twitter_user, 'users': following_resp})
  562. #following = list(map(lambda f: f['id'], following_resp.get('data')))
  563. following = list(map(user_model_dc, following_resp.data))
  564. total_count = following_resp.meta.get('result_count')
  565. next_token = following_resp.meta.get('next_token')
  566. collection_page = CollectionPage(
  567. id = user_id,
  568. items = following,
  569. total_count = total_count,
  570. next_token = next_token
  571. )
  572. return collection_page
  573. def get_followers_user (user_id, me=None, max_results=1000, pagination_token=None):
  574. if me:
  575. twitter_user = session.get(me)
  576. token = twitter_user['access_token']
  577. auth_user_id = twitter_user['id']
  578. else:
  579. token = os.environ.get('BEARER_TOKEN')
  580. auth_user_id = None
  581. use_cache = False # this concept is broken for now
  582. if use_cache: # this concept is broken for now
  583. print(f'using cache for user {user_id}: {use_cache}')
  584. with open(f'.data/cache/followers_{user_id}_{pagination_token}_{use_cache}.json', 'rt') as f:
  585. response_json = json.load(f)
  586. else:
  587. social_source = TwitterApiV2SocialGraph(token)
  588. followers_resp = social_source.get_followers(user_id, max_results=max_results, pagination_token=pagination_token, return_dataclass=True)
  589. ts = int(time.time() * 1000)
  590. print(f'followers cache for {user_id}: {ts}')
  591. cache_users_response(followers_resp, 'followers', auth_user_id, user_id = user_id, pagination_token=pagination_token)
  592. with open(f'{DATA_DIR}/cache/followers_{user_id}_{ts}.json', 'wt') as f:
  593. json.dump(cleandict(asdict(followers_resp)), f, indent=2)
  594. #print(followers_resp)
  595. #run_script('on_user_seen', {'twitter_user': g.twitter_user, 'users': followers_resp})
  596. #followers = list(map(lambda f: f['id'], followers_resp.get('data')))
  597. followers = followers_resp.data
  598. followers = list(map(user_model_dc, followers))
  599. followers = list(map(user_model_dc, followers_resp.data))
  600. total_count = followers_resp.meta.get('result_count')
  601. next_token = followers_resp.meta.get('next_token')
  602. collection_page = CollectionPage(
  603. id = user_id,
  604. items = followers,
  605. total_count = total_count,
  606. next_token = next_token
  607. )
  608. return collection_page
  609. def register_content_sources ():
  610. init_cache_db()
  611. register_content_source('twitter:tweets', get_tweets_collection, id_pattern='')
  612. register_content_source('twitter:tweet:', get_tweet_item, id_pattern='(?P<tweet_id>\d+)')
  613. register_content_source('twitter:tweet:', get_tweet_embed, id_pattern='(?P<tweet_id>\d+)')
  614. register_content_source('twitter:bookmarks:', get_bookmarks_feed, id_pattern='(?P<user_id>\d+)')
  615. register_content_source('twitter:feed:user:', get_user_feed, id_pattern='(?P<user_id>\d+)')
  616. register_content_source('twitter:user:', get_user, id_pattern='(?P<user_id>\d+)')
  617. register_content_source('twitter:users', get_users, id_pattern='')
  618. register_content_source('twitter:feed:reverse_chronological:user:', get_home_feed, id_pattern='(?P<user_id>\d+)')
  619. register_content_source('twitter:tweets:replies:', get_tweet_replies, id_pattern='(?P<conversation_id>\d+)')
  620. register_content_source('twitter:following:users:', get_following_users, id_pattern='(?P<user_id>\d+)')
  621. register_content_source('twitter:followers:user:', get_followers_user, id_pattern='(?P<user_id>\d+)')
  622. register_content_source('twitter:threads:user:', get_author_threads, id_pattern='(?P<user_id>\d+)')