mastodon_facade.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. from configparser import ConfigParser
  2. import base64
  3. from flask import json, Response, render_template, request, send_from_directory, Blueprint, url_for, session, redirect, g
  4. from flask_cors import CORS
  5. import sqlite3
  6. import os
  7. import json
  8. import json_stream
  9. from zipfile import ZipFile
  10. import itertools
  11. from urllib.parse import urlencode as urlencode_qs
  12. import datetime
  13. import dateutil
  14. import dateutil.parser
  15. import dateutil.tz
  16. import requests
  17. from mastodon_source import MastodonAPSource
  18. from mastodon_v2_types import Status
  19. from view_model import FeedItem, FeedItemAction, PublicMetrics
  20. DATA_DIR='.data'
  21. twitter_app = Blueprint('mastodon_facade', 'mastodon_facade',
  22. static_folder='static',
  23. static_url_path='',
  24. url_prefix='/')
  25. @twitter_app.before_request
  26. def add_module_nav_to_template_context ():
  27. me = request.args.get('me')
  28. if me and me.startswith('mastodon:'):
  29. youtube_user = session[ me ]
  30. g.module_nav = [
  31. dict(
  32. href = url_for('.get_timeline_home_html', me=me),
  33. label = 'Public Timeline',
  34. order = 100
  35. ),
  36. dict(
  37. href = url_for('.get_bookmarks_html', me=me),
  38. label = 'Bookmarks',
  39. order = 200
  40. )
  41. ]
  42. def mastodon_model_dc_vm (post_data: Status) -> FeedItem:
  43. """
  44. This is the method we should use. The others should be refactored out.
  45. """
  46. user = post_data.account
  47. source_url = post_data.url
  48. avi_icon_url = user.avatar
  49. url = url_for('mastodon_facade.get_tweet_html', tweet_id=post_data.id)
  50. actions = {
  51. 'bookmark': FeedItemAction('mastodon_facade.post_tweet_bookmark', {'tweet_id': post_data.id}),
  52. 'delete_bookmark': FeedItemAction('mastodon_facade.delete_tweet_bookmark', {'tweet_id': post_data.id}),
  53. 'retweet': FeedItemAction('mastodon_facade.post_tweet_retweet', {'tweet_id': post_data.id})
  54. }
  55. t = FeedItem(
  56. id = post_data.id,
  57. # hugely not a fan of allowing the server's HTML out. can we sanitize it ourselves?
  58. html = post_data.content,
  59. url = url,
  60. source_url = source_url,
  61. created_at = post_data.created_at,
  62. avi_icon_url = avi_icon_url,
  63. display_name = user.display_name,
  64. handle = user.acct,
  65. author_url = url_for('mastodon_facade.get_profile_html', user_id = user.id, me='mastodon:mastodon.cloud:109271381872332822'),
  66. source_author_url = user.url,
  67. public_metrics = PublicMetrics(
  68. reply_count = post_data.replies_count,
  69. retweet_count = post_data.reblogs_count,
  70. like_count = post_data.favourites_count
  71. ),
  72. actions = actions,
  73. debug_source_data = post_data
  74. )
  75. return t
  76. def register_app (instance):
  77. endpoint_url = g.app_url
  78. redirect_uri = endpoint_url + og_url_for('.get_loggedin_html')
  79. client_name = os.environ.get('MASTODON_CLIENT_NAME')
  80. url = f'https://{instance}/api/v1/apps'
  81. params = {
  82. 'client_name': client_name,
  83. 'redirect_uris': redirect_uri,
  84. 'scopes': 'read write'
  85. }
  86. resp = requests.post(url, params=params)
  87. with open(f'{DATA_DIR}/mastodon-client_{instance}.json', 'wt') as f:
  88. f.write(resp.text)
  89. return resp
  90. @twitter_app.get('/api/app/<instance>/register')
  91. def post_api_app_instance_register (instance):
  92. if not instance:
  93. return 'pass isntance= in request params', 400
  94. resp = register_app(instance)
  95. return resp.text, resp.status_code
  96. @twitter_app.get('/api/app/<instance>')
  97. def get_api_app_instance (instance):
  98. with open(f'{DATA_DIR}/mastodon-client_{instance}.json', 'rt') as f:
  99. return Response(f.read(), mimetype='application/json')
  100. @twitter_app.get('/login.html')
  101. def get_login_html ():
  102. instance = request.args.get('instance')
  103. force_login = request.args.get('force_login')
  104. if not instance:
  105. return 'provide instance= in query string.', 400
  106. if not os.path.exists(f'{DATA_DIR}/mastodon-client_{instance}.json'):
  107. resp = register_app(instance)
  108. print(resp)
  109. with open(f'{DATA_DIR}/mastodon-client_{instance}.json', 'rt') as f:
  110. app_info = json.loads(f.read())
  111. params = {
  112. 'client_id': app_info['client_id'],
  113. 'redirect_uri': app_info['redirect_uri'],
  114. 'scope': 'read write',
  115. 'response_type': 'code'
  116. }
  117. if force_login:
  118. params['force_login'] = force_login
  119. url = f'https://{instance}/oauth/authorize?' + urlencode_qs(params)
  120. return redirect(url)
  121. @twitter_app.get('/logged-in.html')
  122. def get_logged_in_html ():
  123. code = request.args.get('code')
  124. instance = request.args.get('instance', 'mastodon.cloud')
  125. if not instance:
  126. return 'provide instance= in query string.', 400
  127. with open(f'{DATA_DIR}/mastodon-client_{instance}.json', 'rt') as f:
  128. app_info = json.loads(f.read())
  129. params = {
  130. 'client_id': app_info['client_id'],
  131. 'client_secret': app_info['client_secret'],
  132. 'redirect_uri': app_info['redirect_uri'],
  133. 'scope': 'read write',
  134. 'grant_type': 'authorization_code',
  135. 'code': code,
  136. # force_login: True
  137. }
  138. url = f'https://{instance}/oauth/token'
  139. resp = requests.post(url, data=params)
  140. auth_info = json.loads(resp.text)
  141. #auth_info = {"access_token":"6C6-hD2_OK1vDFc_WcPQnZC5KL0jOUePgQgMQELeV0k","token_type":"Bearer","scope":"read write","created_at":1670088545}
  142. access_token = auth_info['access_token']
  143. url = f'https://{instance}/api/v1/accounts/verify_credentials'
  144. headers = {
  145. 'Authorization': f'Bearer {access_token}'
  146. }
  147. resp = requests.get(url, headers=headers)
  148. mastodon_user = json.loads(resp.text)
  149. me = f'mastodon:{instance}:{mastodon_user["id"]}'
  150. session_info = {
  151. **auth_info,
  152. 'instance': instance,
  153. 'id': mastodon_user['id'],
  154. 'acct': mastodon_user['acct'],
  155. 'username': mastodon_user['username'],
  156. 'display_name': mastodon_user['display_name'],
  157. 'source_url': mastodon_user['url'],
  158. 'created_at': mastodon_user['created_at'],
  159. }
  160. session[me] = session_info
  161. return redirect(url_for('.get_timeline_home_html', me=me))
  162. #return resp.text, resp.status_code
  163. @twitter_app.post('/data/statuses')
  164. def post_tweets_create ():
  165. me = request.args.get('me')
  166. mastodon_user = session[me]
  167. instance = mastodon_user['instance']
  168. access_token = mastodon_user["access_token"]
  169. text = request.form.get('text')
  170. in_reply_to_id = request.form.get('reply_to_tweet_id')
  171. headers = {
  172. 'Authorization': f'Bearer {access_token}'
  173. }
  174. params = {
  175. 'text': text
  176. }
  177. if in_reply_to_id:
  178. params['in_reply_to_id'] = in_reply_to_id
  179. url = f'https://{instance}/api/v1/statuses/{tweet_id}/bookmark'
  180. resp = requests.post(url, data=params, headers=headers)
  181. new_status = json.loads(resp.text)
  182. new_tweet_id=new_status['id']
  183. if 'HX-Request' in request.headers:
  184. return render_template('partial/compose-form.html', new_tweet_id=new_tweet_id, me=me)
  185. else:
  186. return resp.text, resp.status_code
  187. @twitter_app.post('/data/status/<tweet_id>/retweet')
  188. def post_tweet_retweet (tweet_id):
  189. me = request.args.get('me')
  190. mastodon_user = session[me]
  191. instance = mastodon_user['instance']
  192. access_token = mastodon_user["access_token"]
  193. headers = {
  194. 'Authorization': f'Bearer {access_token}'
  195. }
  196. url = f'https://{instance}/api/v1/statuses/{tweet_id}/reblog'
  197. resp = requests.post(url, headers=headers)
  198. # new_status = json.loads(resp.text)
  199. # new_tweet_id=new_status['id']
  200. ## old status is in reblog: {id:} property.
  201. # if 'HX-Request' in request.headers:
  202. # return 'retweeted, new ID: ' + new_tweet_id
  203. # else:
  204. return resp.text, resp.status_code
  205. @twitter_app.get('/status/<tweet_id>.html')
  206. @twitter_app.get('/statuses.html')
  207. def get_tweet_html (tweet_id = None):
  208. if not tweet_id:
  209. ids = request.args.get('ids').split(',')
  210. return f'tweets: {ids}'
  211. else:
  212. return 'tweet: {tweet_id}'
  213. @twitter_app.get('/profile/<user_id>.html')
  214. def get_profile_html (user_id):
  215. me = request.args.get('me')
  216. mastodon_user = session[me]
  217. instance = mastodon_user['instance']
  218. access_token = mastodon_user["access_token"]
  219. max_id = request.args.get('max_id')
  220. headers = {
  221. 'Authorization': f'Bearer {access_token}'
  222. }
  223. params = {}
  224. try:
  225. # if the UID isn't numeric then we'll fallback to string.
  226. url = f'https://{instance}/api/v1/accounts/{int(user_id)}'
  227. except:
  228. url = f'https://{instance}/api/v1/accounts/lookup'
  229. params = {
  230. 'acct': user_id
  231. }
  232. resp = requests.get(url, params=params, headers=headers)
  233. account_info = json.loads(resp.text)
  234. user_id = account_info["id"]
  235. url = f'https://{instance}/api/v1/accounts/{user_id}/statuses'
  236. params = {}
  237. if max_id:
  238. params['max_id'] = max_id
  239. resp = requests.get(url, params=params, headers=headers)
  240. tweets = json.loads(resp.text)
  241. #tweets = tweet_source.get_timeline("public", timeline_params)
  242. max_id = tweets[-1]["id"] if len(tweets) else ''
  243. tweets = list(map(mastodon_model_dc_vm, tweets))
  244. print("max_id = " + max_id)
  245. query = {}
  246. if len(tweets):
  247. query = {
  248. #'next_data_hx_select': '#tweets',
  249. 'next_data_url': url_for('.get_profile_html', user_id=user_id, me=me, max_id=max_id),
  250. 'next_page_url': url_for('.get_profile_html', user_id=user_id, me=me, max_id=max_id)
  251. }
  252. user = {
  253. 'id': user_id
  254. }
  255. #return Response(response_json, mimetype='application/json')
  256. if 'HX-Request' in request.headers:
  257. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query,
  258. me=me, mastodon_user=mastodon_user)
  259. else:
  260. return render_template('user-profile.html', user = user, tweets = tweets, query = query,
  261. me=me, mastodon_user=mastodon_user)
  262. @twitter_app.route('/latest.html', methods=['GET'])
  263. def get_timeline_home_html (variant = "reverse_chronological"):
  264. me = request.args.get('me')
  265. mastodon_user = session[me]
  266. instance = mastodon_user.get('instance')
  267. token = mastodon_user.get('access_token')
  268. max_id = request.args.get('max_id')
  269. tweet_source = MastodonAPSource(f"https://{instance}", token)
  270. timeline_params = {
  271. 'local': True
  272. }
  273. if max_id:
  274. timeline_params['max_id'] = max_id
  275. tweets = tweet_source.get_timeline("public", timeline_params, return_dataclasses=True)
  276. max_id = tweets[-1].id if len(tweets) else ''
  277. tweets = list(map(mastodon_model_dc_vm, tweets))
  278. print("max_id = " + max_id)
  279. query = {}
  280. if len(tweets):
  281. query = {
  282. 'next_data_url': url_for('.get_timeline_home_html', max_id=max_id, me=me),
  283. 'next_page_url': url_for('.get_timeline_home_html', max_id=max_id, me=me)
  284. }
  285. user = {}
  286. #return Response(response_json, mimetype='application/json')
  287. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query,
  288. me=me, mastodon_user=mastodon_user)
  289. @twitter_app.get('/bookmarks.html')
  290. def get_bookmarks_html ():
  291. me = request.args.get('me')
  292. mastodon_user = session[me]
  293. instance = mastodon_user.get('instance')
  294. token = mastodon_user.get('access_token')
  295. max_id = request.args.get('max_id')
  296. tweet_source = MastodonAPSource(f"https://{instance}", token)
  297. tweets = tweet_source.get_bookmarks(max_id=max_id, return_dataclasses=True)
  298. #tweets = tweet_source.get_timeline("public", timeline_params)
  299. max_id = tweets[-1].id if len(tweets) else ''
  300. tweets = list(map(mastodon_model_dc_vm, tweets))
  301. print("max_id = " + max_id)
  302. query = {}
  303. if len(tweets):
  304. query = {
  305. 'next_data_url': url_for('.get_bookmarks_html', me=me, max_id=max_id),
  306. 'next_page_url': url_for('.get_bookmarks_html', me=me, max_id=max_id)
  307. }
  308. user = {}
  309. #return Response(response_json, mimetype='application/json')
  310. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query,
  311. me=me, mastodon_user=mastodon_user)
  312. @twitter_app.post('/data/bookmarks/<tweet_id>')
  313. def post_tweet_bookmark (tweet_id):
  314. me = request.args.get('me')
  315. mastodon_user = session[me]
  316. instance = mastodon_user['instance']
  317. access_token = mastodon_user["access_token"]
  318. max_id = request.args.get('max_id')
  319. headers = {
  320. 'Authorization': f'Bearer {access_token}'
  321. }
  322. params = {}
  323. url = f'https://{instance}/api/v1/statuses/{tweet_id}/bookmark'
  324. resp = requests.post(url, headers=headers)
  325. return resp.text, resp.status_code
  326. @twitter_app.delete('/data/bookmarks/<tweet_id>')
  327. def delete_tweet_bookmark (tweet_id):
  328. me = request.args.get('me')
  329. mastodon_user = session[me]
  330. instance = mastodon_user['instance']
  331. access_token = mastodon_user["access_token"]
  332. max_id = request.args.get('max_id')
  333. headers = {
  334. 'Authorization': f'Bearer {access_token}'
  335. }
  336. params = {}
  337. url = f'https://{instance}/api/v1/statuses/{tweet_id}/unbookmark'
  338. resp = requests.post(url, headers=headers)
  339. return resp.text, resp.status_code