index.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import { Constant, Tweet } from '../../db/models'
  2. import axios from 'axios'
  3. import { getAuthClient, getTwitterClient } from './clients'
  4. import {
  5. makePaginate,
  6. PaginateOptions,
  7. PaginationConnection,
  8. } from 'sequelize-cursor-pagination'
  9. import { TweetData } from '../../db/models/Tweet'
  10. const STATE = 'banana'
  11. async function generateAuthURL(): Promise<string> {
  12. const authClient = await getAuthClient()
  13. const authUrl = authClient.generateAuthURL({
  14. state: STATE,
  15. code_challenge_method: 's256',
  16. })
  17. return authUrl
  18. }
  19. async function saveAccessToken(token: any): Promise<void> {
  20. await Constant.set('xBotToken', token)
  21. }
  22. async function requestAccessToken(code: string, state: string): Promise<void> {
  23. if (state !== STATE) {
  24. throw new Error("State isn't matching")
  25. }
  26. const authClient = await getAuthClient()
  27. const token = await authClient.requestAccessToken(code)
  28. await saveAccessToken(token.token)
  29. }
  30. async function refreshAccessToken(): Promise<void> {
  31. const authClient = await getAuthClient()
  32. const token = await authClient.refreshAccessToken()
  33. await saveAccessToken(token.token)
  34. }
  35. const tweetUrlReg = /^https:\/\/x\.com\/[a-zA-Z0-9_]+\/status\/([0-9]+)$/
  36. const removeScriptReg = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
  37. async function genenrateTweetByPublishAPI(urlRaw: string): Promise<Tweet> {
  38. const url = urlRaw.trim().split('?')[0]
  39. const match = url.match(tweetUrlReg)
  40. if (!match) {
  41. throw new Error('Invalid tweet URL')
  42. }
  43. const statusId = match[1]
  44. try {
  45. const resp = await axios.get('https://publish.twitter.com/oembed', {
  46. params: {
  47. url,
  48. partner: '',
  49. hideConversation: 'on',
  50. hide_thread: 'true',
  51. omit_script: 'false',
  52. },
  53. })
  54. const data = resp.data
  55. if (!data.html || !data.author_name) {
  56. throw new Error('Failed to fetch tweet HTML')
  57. }
  58. const cleanHTML = data.html.replace(removeScriptReg, '').trim()
  59. const [tweet, created] = await Tweet.findOrCreate({
  60. where: {
  61. statusId,
  62. },
  63. defaults: {
  64. statusId,
  65. username: data.author_name,
  66. url,
  67. body: data,
  68. cleanHTML,
  69. },
  70. })
  71. if (!created) {
  72. tweet.setDataValue('username', data.author_name)
  73. tweet.setDataValue('url', url)
  74. tweet.setDataValue('body', data)
  75. tweet.setDataValue('cleanHTML', cleanHTML)
  76. tweet.setDataValue('disabled', false)
  77. await tweet.save()
  78. }
  79. return tweet
  80. } catch (error: any) {
  81. if (error.response?.data?.error) {
  82. throw new Error(error.response.data.error)
  83. }
  84. throw error
  85. }
  86. }
  87. async function updateTweet(statusId: string): Promise<Tweet> {
  88. const tweet = await Tweet.findOne({ where: { statusId } })
  89. if (!tweet) {
  90. throw new Error('Tweet not found')
  91. }
  92. return await genenrateTweetByPublishAPI(tweet.url)
  93. }
  94. // console.log(Tweet.rawAttributes)
  95. let paginate: (
  96. this: unknown,
  97. queryOptions: PaginateOptions<Tweet>
  98. ) => Promise<PaginationConnection<Tweet>>
  99. async function paginateTweetByOrder(
  100. after?: string,
  101. limit = 24
  102. ): Promise<PaginationConnection<TweetData>> {
  103. if (!paginate) {
  104. paginate = makePaginate(Tweet)
  105. }
  106. const secondResult = await paginate({
  107. order: [
  108. ['order', 'DESC'],
  109. ['id', 'DESC'],
  110. ],
  111. limit,
  112. after,
  113. where: {
  114. disabled: false,
  115. },
  116. })
  117. const ret: PaginationConnection<TweetData> = {
  118. ...secondResult,
  119. edges: secondResult.edges.map((edge) => ({
  120. cursor: edge.node.id,
  121. node: edge.node.getData(),
  122. })),
  123. }
  124. return ret
  125. }
  126. export interface GenerateResult {
  127. url: string
  128. success: boolean
  129. error?: string
  130. }
  131. async function bulkGenerateTweetsByPublishAPI(
  132. urls: string[]
  133. ): Promise<GenerateResult[]> {
  134. const results: GenerateResult[] = []
  135. for (const url of urls) {
  136. try {
  137. const tweet = await genenrateTweetByPublishAPI(url)
  138. results.push({ url, success: true })
  139. } catch (e) {
  140. results.push({
  141. url,
  142. success: false,
  143. error: (e as Error).message ?? 'unknown error',
  144. })
  145. }
  146. }
  147. return results
  148. }
  149. async function makeTweetTop(statusId: string, top = true): Promise<void> {
  150. const tweet = await Tweet.findOne({ where: { statusId } })
  151. if (!tweet) {
  152. throw new Error('Tweet not found')
  153. }
  154. if (top) {
  155. const maxOrder: number = await Tweet.max('order')
  156. tweet.setDataValue('order', maxOrder ? maxOrder + 1 : 1)
  157. } else {
  158. tweet.setDataValue('order', 0)
  159. }
  160. await tweet.save()
  161. }
  162. async function makeTweetDisabled(
  163. statusId: string,
  164. disabled = true
  165. ): Promise<void> {
  166. const tweet = await Tweet.findOne({ where: { statusId } })
  167. if (!tweet) {
  168. throw new Error('Tweet not found')
  169. }
  170. tweet.setDataValue('disabled', disabled)
  171. await tweet.save()
  172. }
  173. export interface ShotData {
  174. url: string
  175. shot: string
  176. }
  177. async function importShot(data: ShotData): Promise<Tweet> {
  178. const { shot: shotRaw, url: urlRaw } = data
  179. try {
  180. return await genenrateTweetByPublishAPI(urlRaw)
  181. } catch (error) {
  182. // if error, use shot
  183. }
  184. const shot = shotRaw.trim()
  185. if (!shot?.startsWith('https://')) {
  186. throw new Error('Invalid shot image URL')
  187. }
  188. const url = urlRaw.trim().split('?')[0]
  189. const match = url.match(tweetUrlReg)
  190. if (!match) {
  191. throw new Error('Invalid tweet URL')
  192. }
  193. const statusId = match[1]
  194. try {
  195. const [tweet, created] = await Tweet.findOrCreate({
  196. where: {
  197. statusId,
  198. },
  199. defaults: {
  200. statusId,
  201. username: '',
  202. url,
  203. body: data,
  204. cleanHTML: '',
  205. },
  206. })
  207. if (!created) {
  208. tweet.setDataValue('username', '')
  209. tweet.setDataValue('url', url)
  210. tweet.setDataValue('body', data)
  211. tweet.setDataValue('cleanHTML', '')
  212. tweet.setDataValue('disabled', false)
  213. await tweet.save()
  214. }
  215. return tweet
  216. } catch (error: any) {
  217. if (error.response?.data?.error) {
  218. throw new Error(error.response.data.error)
  219. }
  220. throw error
  221. }
  222. }
  223. async function bulkImportShot(datas: ShotData[]): Promise<GenerateResult[]> {
  224. const results: GenerateResult[] = []
  225. for (const data of datas) {
  226. try {
  227. const tweet = await importShot(data)
  228. results.push({ url: data.url, success: true })
  229. } catch (e) {
  230. results.push({
  231. url: data.url,
  232. success: false,
  233. error: (e as Error).message ?? 'unknown error',
  234. })
  235. }
  236. }
  237. return results
  238. }
  239. const twitterService = {
  240. getTwitterClient,
  241. generateAuthURL,
  242. STATE,
  243. genenrateTweetByPublishAPI,
  244. requestAccessToken,
  245. refreshAccessToken,
  246. paginateTweetByOrder,
  247. updateTweet,
  248. bulkGenerateTweetsByPublishAPI,
  249. makeTweetTop,
  250. makeTweetDisabled,
  251. importShot,
  252. bulkImportShot,
  253. }
  254. export default twitterService