api.nim 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. # SPDX-License-Identifier: AGPL-3.0-only
  2. import asyncdispatch, httpclient, strutils, sequtils, sugar
  3. import packedjson
  4. import types, query, formatters, consts, apiutils, parser, utils
  5. import experimental/parser
  6. # Helper to generate params object for GraphQL requests
  7. proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
  8. result.add ("variables", variables)
  9. result.add ("features", gqlFeatures)
  10. if fieldToggles.len > 0:
  11. result.add ("fieldToggles", fieldToggles)
  12. proc apiUrl(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiUrl =
  13. return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles), skipTid: skipTid)
  14. proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiReq =
  15. let url = apiUrl(endpoint, variables, fieldToggles, skipTid)
  16. return ApiReq(cookie: url, oauth: url)
  17. proc cursorParam(after: string): string =
  18. ## JSON-escape the user-supplied cursor so it cannot break out of the GraphQL
  19. ## variables object (same input-validation class as the #1411 media SSRF).
  20. if after.len > 0: "\"cursor\":" & $(%after) & "," else: ""
  21. proc mediaUrl(id, cursor: string; count=20): ApiReq =
  22. result = ApiReq(
  23. cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]),
  24. oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count])
  25. )
  26. proc userTweetsUrl(id: string; cursor: string): ApiReq =
  27. return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles)
  28. proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
  29. return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true)
  30. proc tweetDetailUrl(id: string; cursor: string): ApiReq =
  31. return apiReq(graphTweet, tweetVars % [id, cursor])
  32. # let cookieVars = tweetDetailVars % [id, cursor]
  33. # result = ApiReq(
  34. # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
  35. # oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
  36. # )
  37. proc userUrl(username: string): ApiReq =
  38. let cookieVars = $(%*{"screen_name": username, "withGrokTranslatedBio": false})
  39. result = ApiReq(
  40. cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
  41. oauth: apiUrl(graphUserV2, $(%*{"screen_name": username}))
  42. )
  43. proc getGraphUser*(username: string): Future[User] {.async.} =
  44. if username.len == 0: return
  45. let js = await fetchRaw(userUrl(username))
  46. result = parseGraphUser(js)
  47. proc getGraphUserById*(id: string): Future[User] {.async.} =
  48. if id.len == 0 or id.any(c => not c.isDigit): return
  49. let
  50. url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
  51. js = await fetchRaw(url)
  52. result = parseGraphUser(js)
  53. proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} =
  54. if username.len == 0: return
  55. let
  56. url = apiReq(graphAboutAccount, $(%*{"screenName": username}))
  57. js = await fetch(url)
  58. result = parseAboutAccount(js)
  59. proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq =
  60. let url = ApiUrl(endpoint: endpoint, params: params)
  61. ApiReq(cookie: url, oauth: url)
  62. proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} =
  63. if id.len == 0: return
  64. let
  65. req = apiReq(graphBroadcast, $(%*{"id": id}))
  66. js = await fetch(req)
  67. result = parseBroadcastInfo(js)
  68. proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} =
  69. if mediaKey.len == 0: return
  70. let
  71. streamReq = restReq(restLiveStream & mediaKey)
  72. streamJs = await fetch(streamReq)
  73. result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr(
  74. streamJs{"source", "location"}.getStr)
  75. proc getAudioSpace*(id: string): Future[AudioSpace] {.async.} =
  76. if id.len == 0: return
  77. let
  78. variables = %*{
  79. "id": id,
  80. "isMetatagsQuery": false,
  81. "withReplays": true,
  82. "withListeners": true
  83. }
  84. req = apiReq(graphAudioSpace, $variables)
  85. js = await fetch(req)
  86. result = parseAudioSpace(js)
  87. proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
  88. if id.len == 0: return
  89. let
  90. cursor = cursorParam(after)
  91. url = case kind
  92. of TimelineKind.tweets: userTweetsUrl(id, cursor)
  93. of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
  94. of TimelineKind.media: mediaUrl(id, cursor, 100)
  95. js = await fetch(url)
  96. result = parseGraphTimeline(js, after)
  97. proc getGraphCommunity*(id: string): Future[Community] {.async.} =
  98. if id.len == 0: return
  99. let
  100. url = apiReq(graphCommunity, $(%*{"communityId": id}))
  101. js = await fetch(url)
  102. result = parseGraphCommunity(js)
  103. proc getGraphCommunityTweets*(id: string; rankingMode: string; after=""): Future[Timeline] {.async.} =
  104. if id.len == 0: return
  105. let
  106. cursor = cursorParam(after)
  107. url = apiReq(graphCommunityTweets, communityTweetsVars % [id, cursor, rankingMode])
  108. js = await fetch(url)
  109. result = parseGraphCommunityTimeline(js, after)
  110. proc getGraphCommunityMedia*(id: string; after=""): Future[Timeline] {.async.} =
  111. if id.len == 0: return
  112. let
  113. cursor = cursorParam(after)
  114. url = apiReq(graphCommunityMedia, communityMediaVars % [id, cursor])
  115. js = await fetch(url)
  116. result = parseGraphCommunityTimeline(js, after)
  117. proc communitySliceReq(endpoint, variables: string): ApiReq =
  118. let url = ApiUrl(endpoint: endpoint, params: @[("variables", variables)])
  119. ApiReq(cookie: url, oauth: url)
  120. proc getGraphCommunityMembers*(id: string; after=""): Future[Result[User]] {.async.} =
  121. if id.len == 0: return
  122. let
  123. cursor = if after.len > 0: $(%after) else: "null"
  124. url = communitySliceReq(graphCommunityMembers, communityMembersVars % [id, cursor])
  125. js = await fetch(url)
  126. result = parseGraphCommunityMembers(js, after)
  127. proc getGraphCommunityModerators*(id: string): Future[Result[User]] {.async.} =
  128. if id.len == 0: return
  129. let
  130. url = communitySliceReq(graphCommunityModerators, communityMembersVars % [id, "null"])
  131. js = await fetch(url)
  132. result = parseGraphCommunityMembers(js)
  133. proc getGraphCommunityHashtags*(id, hashtag: string; after=""): Future[Timeline] {.async.} =
  134. if id.len == 0 or hashtag.len == 0: return
  135. let
  136. safeTag = multiReplace(hashtag, ("\"", ""), ("\\", ""))
  137. cursor = cursorParam(after)
  138. url = apiReq(graphCommunityHashtags, communityHashtagsVars % [id, cursor, safeTag])
  139. js = await fetch(url)
  140. result = parseGraphCommunityTimeline(js, after)
  141. proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
  142. if id.len == 0: return
  143. let
  144. cursor = cursorParam(after)
  145. url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
  146. js = await fetch(url)
  147. result = parseGraphTimeline(js, after).tweets
  148. proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
  149. let
  150. variables = %*{"screenName": name, "listSlug": list}
  151. url = apiReq(graphListBySlug, $variables)
  152. js = await fetch(url)
  153. result = parseGraphList(js)
  154. proc getGraphList*(id: string): Future[List] {.async.} =
  155. let
  156. url = apiReq(graphListById, $(%*{"listId": id}))
  157. js = await fetch(url)
  158. result = parseGraphList(js)
  159. proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
  160. if list.id.len == 0: return
  161. var
  162. variables = %*{
  163. "listId": list.id,
  164. "withBirdwatchPivots": false,
  165. "withDownvotePerspective": false,
  166. "withReactionsMetadata": false,
  167. "withReactionsPerspective": false
  168. }
  169. if after.len > 0:
  170. variables["cursor"] = % after
  171. let
  172. url = apiReq(graphListMembers, $variables)
  173. js = await fetchRaw(url)
  174. result = parseGraphListMembers(js, after)
  175. proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
  176. if id.len == 0: return
  177. let
  178. url = apiReq(graphTweetResult, $(%*{"rest_id": id}))
  179. js = await fetch(url)
  180. result = parseGraphTweetResult(js)
  181. proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
  182. if id.len == 0: return
  183. let
  184. cursor = cursorParam(after)
  185. js = await fetch(tweetDetailUrl(id, cursor))
  186. result = parseGraphConversation(js, id)
  187. proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
  188. result = (await getGraphTweet(id, after)).replies
  189. result.beginning = after.len == 0
  190. proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
  191. result = await getGraphTweet(id)
  192. if after.len > 0:
  193. result.replies = await getReplies(id, after)
  194. proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
  195. if id.len == 0: return
  196. let
  197. url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id)
  198. js = await fetch(url)
  199. result = parseGraphEditHistory(js, id)
  200. proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
  201. # workaround for #1372
  202. let maxId =
  203. if not after.startsWith("maxid:"): ""
  204. else: validateNumber(after[6..^1])
  205. let q = genQueryParam(query, maxId)
  206. if q.len == 0 or q == emptyQuery:
  207. return Timeline(query: query, beginning: true)
  208. var
  209. variables = %*{
  210. "rawQuery": q,
  211. "count": 20,
  212. "querySource": "typed_query",
  213. "product": "Latest",
  214. "withGrokTranslatedBio":true,
  215. "withQuickPromoteEligibilityTweetFields":false
  216. }
  217. if after.len > 0 and maxId.len == 0:
  218. variables["cursor"] = % after
  219. let
  220. url = apiReq(graphSearchTimeline, $variables)
  221. js = await fetch(url)
  222. result = parseGraphSearch[Tweets](js, after)
  223. result.query = query
  224. # when no more items are available the API just returns the last page in
  225. # full. this detects that and clears the page instead.
  226. if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and
  227. after[0..<64] == result.bottom[0..<64]:
  228. result.content.setLen(0)
  229. proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
  230. if query.text.len == 0:
  231. return Result[User](query: query, beginning: true)
  232. var
  233. variables = %*{
  234. "rawQuery": query.text,
  235. "count": 20,
  236. "querySource": "typed_query",
  237. "product": "People",
  238. "withGrokTranslatedBio":true,
  239. "withQuickPromoteEligibilityTweetFields":false
  240. }
  241. if after.len > 0:
  242. variables["cursor"] = % after
  243. result.beginning = false
  244. let
  245. url = apiReq(graphSearchTimeline, $variables)
  246. js = await fetch(url)
  247. result = parseGraphSearch[User](js, after)
  248. result.query = query
  249. proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
  250. if id.len == 0: return
  251. let js = await fetch(mediaUrl(id, "", 30))
  252. result = parseGraphPhotoRail(js)
  253. proc getGraphArticle*(id: string): Future[Article] {.async.} =
  254. if id.len == 0: return
  255. let
  256. url = apiReq(graphTweetResultByRestId, articleVars % id, articleFieldToggles)
  257. json = await fetchRaw(url)
  258. result = parseGraphArticle(json)
  259. proc getGraphTweetResults*(ids: seq[string]): Future[seq[Tweet]] {.async.} =
  260. if ids.len == 0: return
  261. let
  262. idsJson = "[" & ids.mapIt("\"" & it & "\"").join(",") & "]"
  263. url = apiReq(graphTweetResultsByRestIds, articleBatchVars % idsJson, articleFieldToggles)
  264. js = await fetch(url)
  265. result = parseGraphTweetResults(js)
  266. proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
  267. let client = newAsyncHttpClient(maxRedirects=0)
  268. try:
  269. let resp = await client.request(url, HttpHead)
  270. result = resp.headers["location"].replaceUrls(prefs)
  271. except:
  272. discard
  273. finally:
  274. client.close()