mastodon_facade.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  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
  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. DATA_DIR='.data'
  18. twitter_app = Blueprint('mastodon_facade', 'mastodon_facade',
  19. static_folder='static',
  20. static_url_path='',
  21. url_prefix='/')
  22. def mastodon_model (post_data):
  23. # retweeted_by, avi_icon_url, display_name, handle, created_at, text
  24. user = post_data['account']
  25. source_url = post_data['url']
  26. avi_icon_url = user['avatar']
  27. url = url_for('.get_tweet_html', tweet_id=post_data['id'])
  28. t = {
  29. 'id': post_data['id'],
  30. # hugely not a fan of allowing the server's HTML out. can we sanitize it ourselves?
  31. 'html': post_data['content'],
  32. 'url': url,
  33. 'source_url': source_url,
  34. 'created_at': post_data['created_at'],
  35. 'avi_icon_url': avi_icon_url,
  36. 'display_name': user['display_name'],
  37. 'handle': user['acct'],
  38. 'author_url': url_for('.get_profile_html', user_id = user['id'], me='mastodon:mastodon.cloud:109271381872332822'),
  39. 'source_author_url': user['url'],
  40. 'public_metrics': {
  41. 'reply_count': post_data['replies_count'],
  42. 'retweet_count': post_data['reblogs_count'],
  43. 'like_count': post_data['favourites_count']
  44. },
  45. 'activity': post_data
  46. }
  47. return t
  48. def register_app (instance):
  49. client_name = os.environ.get('MASTODON_CLIENT_NAME')
  50. redirect_uri = 'http://localhost:5004/mastodon/logged-in.html'
  51. url = f'https://{instance}/api/v1/apps'
  52. params = {
  53. 'client_name': client_name,
  54. 'redirect_uris': redirect_uri,
  55. 'scopes': 'read write'
  56. }
  57. resp = requests.post(url, params=params)
  58. with open(f'{DATA_DIR}/mastodon-client_{instance}.json', 'wt') as f:
  59. f.write(resp.text)
  60. return resp
  61. @twitter_app.get('/api/app/<instance>/register')
  62. def post_api_app_instance_register (instance):
  63. if not instance:
  64. return 'pass isntance= in request params', 400
  65. resp = register_app(instance)
  66. return resp.text, resp.status_code
  67. @twitter_app.get('/api/app/<instance>')
  68. def get_api_app_instance (instance):
  69. with open(f'{DATA_DIR}/mastodon-client_{instance}.json', 'rt') as f:
  70. return Response(f.read(), mimetype='application/json')
  71. @twitter_app.get('/login.html')
  72. def get_login_html ():
  73. instance = request.args.get('instance')
  74. force_login = request.args.get('force_login')
  75. if not instance:
  76. return 'provide instance= in query string.', 400
  77. if not os.path.exists(f'{DATA_DIR}/mastodon-client_{instance}.json'):
  78. resp = register_app(instance)
  79. print(resp)
  80. with open(f'{DATA_DIR}/mastodon-client_{instance}.json', 'rt') as f:
  81. app_info = json.loads(f.read())
  82. params = {
  83. 'client_id': app_info['client_id'],
  84. 'redirect_uri': app_info['redirect_uri'],
  85. 'scope': 'read write',
  86. 'response_type': 'code'
  87. }
  88. if force_login:
  89. params['force_login'] = force_login
  90. url = f'https://{instance}/oauth/authorize?' + urlencode_qs(params)
  91. return redirect(url)
  92. @twitter_app.get('/logged-in.html')
  93. def get_logged_in_html ():
  94. code = request.args.get('code')
  95. instance = request.args.get('instance', 'mastodon.cloud')
  96. if not instance:
  97. return 'provide instance= in query string.', 400
  98. with open(f'{DATA_DIR}/mastodon-client_{instance}.json', 'rt') as f:
  99. app_info = json.loads(f.read())
  100. params = {
  101. 'client_id': app_info['client_id'],
  102. 'client_secret': app_info['client_secret'],
  103. 'redirect_uri': app_info['redirect_uri'],
  104. 'scope': 'read write',
  105. 'grant_type': 'authorization_code',
  106. 'code': code,
  107. # force_login: True
  108. }
  109. url = f'https://{instance}/oauth/token'
  110. resp = requests.post(url, data=params)
  111. auth_info = json.loads(resp.text)
  112. #auth_info = {"access_token":"6C6-hD2_OK1vDFc_WcPQnZC5KL0jOUePgQgMQELeV0k","token_type":"Bearer","scope":"read write","created_at":1670088545}
  113. access_token = auth_info['access_token']
  114. url = f'https://{instance}/api/v1/accounts/verify_credentials'
  115. headers = {
  116. 'Authorization': f'Bearer {access_token}'
  117. }
  118. resp = requests.get(url, headers=headers)
  119. mastodon_user = json.loads(resp.text)
  120. me = f'mastodon:{instance}:{mastodon_user["id"]}'
  121. session_info = {
  122. **auth_info,
  123. 'instance': instance,
  124. 'id': mastodon_user['id'],
  125. 'acct': mastodon_user['acct'],
  126. 'username': mastodon_user['username'],
  127. 'display_name': mastodon_user['display_name'],
  128. 'source_url': mastodon_user['url'],
  129. 'created_at': mastodon_user['created_at'],
  130. }
  131. session[me] = session_info
  132. return redirect(url_for('.get_latest_html', me=me))
  133. #return resp.text, resp.status_code
  134. @twitter_app.post('/data/statuses')
  135. def post_tweets_create ():
  136. me = request.args.get('me')
  137. mastodon_user = session[me]
  138. instance = mastodon_user['instance']
  139. access_token = mastodon_user["access_token"]
  140. text = request.form.get('text')
  141. in_reply_to_id = request.form.get('reply_to_tweet_id')
  142. headers = {
  143. 'Authorization': f'Bearer {access_token}'
  144. }
  145. params = {
  146. 'text': text
  147. }
  148. if in_reply_to_id:
  149. params['in_reply_to_id'] = in_reply_to_id
  150. url = f'https://{instance}/api/v1/statuses/{tweet_id}/bookmark'
  151. resp = requests.post(url, data=params, headers=headers)
  152. new_status = json.loads(resp.text)
  153. new_tweet_id=new_status['id']
  154. if 'HX-Request' in request.headers:
  155. return render_template('partial/compose-form.html', new_tweet_id=new_tweet_id, me=me)
  156. else:
  157. return resp.text, resp.status_code
  158. @twitter_app.post('/data/status/<tweet_id>/retweet')
  159. def post_tweet_retweet (tweet_id):
  160. me = request.args.get('me')
  161. mastodon_user = session[me]
  162. instance = mastodon_user['instance']
  163. access_token = mastodon_user["access_token"]
  164. headers = {
  165. 'Authorization': f'Bearer {access_token}'
  166. }
  167. url = f'https://{instance}/api/v1/statuses/{tweet_id}/reblog'
  168. resp = requests.post(url, headers=headers)
  169. # new_status = json.loads(resp.text)
  170. # new_tweet_id=new_status['id']
  171. ## old status is in reblog: {id:} property.
  172. # if 'HX-Request' in request.headers:
  173. # return 'retweeted, new ID: ' + new_tweet_id
  174. # else:
  175. return resp.text, resp.status_code
  176. @twitter_app.get('/status/<tweet_id>.html')
  177. def get_tweet_html (tweet_id):
  178. return 'tweet: ' + tweet_id
  179. @twitter_app.get('/profile/<user_id>.html')
  180. def get_profile_html (user_id):
  181. me = request.args.get('me')
  182. mastodon_user = session[me]
  183. instance = mastodon_user['instance']
  184. access_token = mastodon_user["access_token"]
  185. max_id = request.args.get('max_id')
  186. headers = {
  187. 'Authorization': f'Bearer {access_token}'
  188. }
  189. params = {}
  190. try:
  191. # if the UID isn't numeric then we'll fallback to string.
  192. url = f'https://{instance}/api/v1/accounts/{int(user_id)}'
  193. except:
  194. url = f'https://{instance}/api/v1/accounts/lookup'
  195. params = {
  196. 'acct': user_id
  197. }
  198. resp = requests.get(url, params=params, headers=headers)
  199. account_info = json.loads(resp.text)
  200. user_id = account_info["id"]
  201. url = f'https://{instance}/api/v1/accounts/{user_id}/statuses'
  202. params = {}
  203. if max_id:
  204. params['max_id'] = max_id
  205. resp = requests.get(url, params=params, headers=headers)
  206. tweets = json.loads(resp.text)
  207. #tweets = tweet_source.get_timeline("public", timeline_params)
  208. max_id = tweets[-1]["id"] if len(tweets) else ''
  209. tweets = list(map(mastodon_model, tweets))
  210. print("max_id = " + max_id)
  211. query = {}
  212. if len(tweets):
  213. query = {
  214. #'next_data_hx_select': '#tweets',
  215. 'next_data_url': url_for('.get_profile_html', user_id=user_id, me=me, max_id=max_id),
  216. 'next_page_url': url_for('.get_profile_html', user_id=user_id, me=me, max_id=max_id)
  217. }
  218. user = {
  219. 'id': user_id
  220. }
  221. #return Response(response_json, mimetype='application/json')
  222. if 'HX-Request' in request.headers:
  223. return render_template('partial/tweets-timeline.html', user = user, tweets = tweets, query = query,
  224. me=me, mastodon_user=mastodon_user)
  225. else:
  226. return render_template('user-profile.html', user = user, tweets = tweets, query = query,
  227. me=me, mastodon_user=mastodon_user)
  228. @twitter_app.route('/latest.html', methods=['GET'])
  229. def get_timeline_home_html (variant = "reverse_chronological"):
  230. # retweeted_by, avi_icon_url, display_name, handle, created_at, text
  231. me = request.args.get('me')
  232. mastodon_user = session[me]
  233. token = mastodon_user.get('access_token')
  234. max_id = request.args.get('max_id')
  235. # comes from token or session
  236. user_id = "ispoogedaily"
  237. if not token:
  238. print("No token provided or found in environ.")
  239. return Response('{"err": "No token."}', mimetype="application/json", status=400)
  240. tweet_source = MastodonAPSource("https://mastodon.cloud", token)
  241. timeline_params = {
  242. 'local': True
  243. }
  244. if max_id:
  245. timeline_params['max_id'] = max_id
  246. tweets = tweet_source.get_timeline("public", timeline_params)
  247. max_id = tweets[-1]["id"] if len(tweets) else ''
  248. tweets = list(map(mastodon_model, tweets))
  249. print("max_id = " + max_id)
  250. query = {}
  251. if len(tweets):
  252. query = {
  253. 'next_data_url': url_for('.get_timeline_home_html', max_id=max_id, me=me),
  254. 'next_page_url': url_for('.get_timeline_home_html', max_id=max_id, me=me)
  255. }
  256. user = {
  257. 'id': user_id
  258. }
  259. #return Response(response_json, mimetype='application/json')
  260. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query,
  261. me=me, mastodon_user=mastodon_user)
  262. @twitter_app.get('/bookmarks.html')
  263. def get_bookmarks_html ():
  264. me = request.args.get('me')
  265. mastodon_user = session[me]
  266. instance = mastodon_user['instance']
  267. access_token = mastodon_user["access_token"]
  268. max_id = request.args.get('max_id')
  269. headers = {
  270. 'Authorization': f'Bearer {access_token}'
  271. }
  272. params = {}
  273. url = f'https://{instance}/api/v1/bookmarks'
  274. params = {}
  275. if max_id:
  276. params['max_id'] = max_id
  277. resp = requests.get(url, params=params, headers=headers)
  278. tweets = json.loads(resp.text)
  279. #tweets = tweet_source.get_timeline("public", timeline_params)
  280. max_id = tweets[-1]["id"] if len(tweets) else ''
  281. tweets = list(map(mastodon_model, tweets))
  282. print("max_id = " + max_id)
  283. query = {}
  284. if len(tweets):
  285. query = {
  286. 'next_data_url': url_for('.get_bookmarks_html', me=me, max_id=max_id),
  287. 'next_page_url': url_for('.get_bookmarks_html', me=me, max_id=max_id)
  288. }
  289. user = {}
  290. #return Response(response_json, mimetype='application/json')
  291. return render_template('tweet-collection.html', user = user, tweets = tweets, query = query,
  292. me=me, mastodon_user=mastodon_user)
  293. @twitter_app.post('/data/bookmarks/<tweet_id>')
  294. def post_tweet_bookmark (tweet_id):
  295. me = request.args.get('me')
  296. mastodon_user = session[me]
  297. instance = mastodon_user['instance']
  298. access_token = mastodon_user["access_token"]
  299. max_id = request.args.get('max_id')
  300. headers = {
  301. 'Authorization': f'Bearer {access_token}'
  302. }
  303. params = {}
  304. url = f'https://{instance}/api/v1/statuses/{tweet_id}/bookmark'
  305. resp = requests.post(url, headers=headers)
  306. return resp.text, resp.status_code
  307. @twitter_app.delete('/data/bookmarks/<tweet_id>')
  308. def delete_tweet_bookmark (tweet_id):
  309. me = request.args.get('me')
  310. mastodon_user = session[me]
  311. instance = mastodon_user['instance']
  312. access_token = mastodon_user["access_token"]
  313. max_id = request.args.get('max_id')
  314. headers = {
  315. 'Authorization': f'Bearer {access_token}'
  316. }
  317. params = {}
  318. url = f'https://{instance}/api/v1/statuses/{tweet_id}/unbookmark'
  319. resp = requests.post(url, headers=headers)
  320. return resp.text, resp.status_code
  321. class MastodonAPSource:
  322. def __init__ (self, endpoint, token):
  323. self.endpoint = endpoint
  324. self.token = token
  325. super().__init__()
  326. def get_timeline (self, path = "home", params = {}):
  327. url = self.endpoint + "/api/v1/timelines/" + path
  328. params = {
  329. **params
  330. }
  331. headers = {"Authorization": "Bearer {}".format(self.token)}
  332. response = requests.get(url, params=params, headers=headers)
  333. print(response)
  334. response_json = json.loads(response.text)
  335. return response_json
  336. # nice parallel is validation
  337. # https://github.com/moko256/twitlatte/blob/master/component_client_mastodon/src/main/java/com/github/moko256/latte/client/mastodon/MastodonApiClientImpl.kt
  338. def get_mentions_timeline (self):
  339. # /api/v1/notifications?exclude_types=follow,favourite,reblog,poll,follow_request
  340. return
  341. def get_favorited_timeline (self):
  342. # /api/v1/notifications?exclude_types=follow,reblog,mention,poll,follow_request
  343. return
  344. def get_conversations_timeline (self):
  345. # /api/v1/conversations
  346. return
  347. def get_bookmarks_timeline (self):
  348. # /account/bookmarks
  349. return
  350. def get_favorites_timeline (self):
  351. # /account/favourites
  352. return
  353. def get_user_statuses (self, account_id):
  354. # /api/v2/search?type=statuses&account_id=
  355. # /api/v1/accounts/:id/statuses
  356. return
  357. def get_statuses (self, ids):
  358. # /api/v1/statuses/:id
  359. return
  360. def get_profile (self, username):
  361. # /api/v1/accounts/search
  362. return self.search('@' + username, result_type='accounts')
  363. def search (q, result_type = None, following = False):
  364. # /api/v2/search
  365. return