bot.js 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. let Mastodon = require('mastodon')
  2. let Twit = require('twit')
  3. // for downloading and saving media
  4. const https = require('https')
  5. const fs = require('fs')
  6. const mime = require('mime-types')
  7. // load the configuration
  8. const config = require('./config.js')
  9. let storage = require('./storage.js')
  10. const TWITTER_MAX_CHARS = 280
  11. const TWITTER_IMAGE_MAX_CHARS = 140 //?
  12. function readBase64(filename) {
  13. let buf = fs.readFileSync(filename)
  14. let b64 = buf.toString('base64')
  15. return b64
  16. }
  17. let bot = {
  18. lastPostDate: new Date().getTime(),
  19. // splits a tweet into pieces that are less than TWITTER_MAX_CHARS long
  20. splitTweet: function (post, pieces = []) {
  21. if (post.length > TWITTER_MAX_CHARS) {
  22. let index = TWITTER_MAX_CHARS - 3 // 3 for appending ...
  23. while (post.charAt(index) !== ' ' && index > 0) index--
  24. if (index === 0) {
  25. /*if a word if longer than TWITTER_MAX_CHARS-3 split the word*/
  26. index = TWITTER_MAX_CHARS - 3
  27. }
  28. return this.splitTweet(post.substring(index+1), pieces.concat([post.substring(0, index) + '...']))
  29. }
  30. return pieces.concat([post])
  31. },
  32. splitForImage: function (post) {
  33. if (post.length > TWITTER_IMAGE_MAX_CHARS) {
  34. let index = TWITTER_IMAGE_MAX_CHARS - 3 // 3 for appending ...
  35. while (post.charAt(index) !== ' ' && index > 0) index--
  36. if (index === 0) {
  37. /*if a word if longer than TWITTER_MAX_CHARS-3 split the word*/
  38. index = TWITTER_IMAGE_MAX_CHARS - 3
  39. }
  40. return [post.substring(0, index) + '...', post.substring(index+1)]
  41. }
  42. return [post]
  43. },
  44. // post to twitter with media attachments
  45. // Size restrictions for uploading via API
  46. // Image 5MB
  47. // GIF 15MB
  48. // Video 15MB
  49. // medias is an array of filenames
  50. twitterPostWithMedias: function (post, medias) {
  51. let pieces = this.splitForImage(post)
  52. let uploads = []
  53. for (let media of medias) {
  54. let b64content = readBase64(media)
  55. uploads.push(
  56. new Promise((resolve, reject) => {
  57. this.twitter.post('media/upload', { media_data: b64content }, (err, data, response) => {
  58. if (err) reject(err)
  59. else resolve(data)
  60. })
  61. })
  62. )
  63. }
  64. return Promise.all(uploads).then(datas => {
  65. let mediaIds = datas.map(data => data.media_id_string)
  66. let altText = 'test'
  67. mediaIds.forEach(mediaId => {
  68. this.twitter.post('media/metadata/create', { media_id: mediaId, alt_text: { text: altText } }, (err, data, response) => {
  69. if (!err) {
  70. this.twitter.post('statuses/update', { status: pieces[0], media_ids: mediaIds }, (err, data, response) => {
  71. if (!err) {
  72. console.log(`[INFO] Tweeted ${post} with ${medias}`)
  73. if (pieces.length > 1) this.twitterPost(pieces[1], data.id_str)
  74. } else throw err
  75. })
  76. } else throw err
  77. })
  78. })
  79. })
  80. },
  81. promisePost: function(post, reply = null) {
  82. let that = this
  83. return new Promise((resolve, reject) => {
  84. that.twitter.post('statuses/update', { status: post, in_reply_to_status_id: reply }, (err, data, response) => {
  85. if (err) reject(err)
  86. else resolve(data)
  87. })
  88. })
  89. },
  90. // post to twitter
  91. // if post is longer than TWITTER_MAX_CHARS divides in multiple tweets
  92. twitterPost: function (post, reply = null) {
  93. let pieces = this.splitTweet(post)
  94. let promise = this.promisePost(pieces[0], reply)
  95. for (let i = 1; i < pieces.length; i++) {
  96. promise = promise.then(data => this.promisePost(pieces[i], data.id_str))
  97. }
  98. promise.then(() => console.log(`[INFO] Tweeted ${post}`))
  99. .catch(err => console.error(err))
  100. },
  101. // downloads a file from the given url and saves it to temp dir
  102. downloadFile: function(url, name, temp = '/tmp') {
  103. return new Promise(resolve => {
  104. https.get(url, response => {
  105. let ext = mime.extension(response.headers['content-type'])
  106. let filename = `${temp}/${name}.${ext}`
  107. const file = fs.createWriteStream(filename)
  108. const stream = response.pipe(file)
  109. stream.on('finish', () => {
  110. console.log(`[INFO] Downloaded ${url} to ${filename}`)
  111. resolve(filename)
  112. })
  113. })
  114. })
  115. },
  116. // makes a post formatted for twitter
  117. // mastodon returns posts in HTML
  118. twitterFormat: function(post) {
  119. return post
  120. .replace(/<br\/>/g, '\n')
  121. .replace(/<\/p>/g, '\n')
  122. .replace(/&apos;/g, '\'')
  123. .replace(/<[^>]*>?/gm, '')
  124. },
  125. // poll on mastodon status updates
  126. start: function() {
  127. this.twitter = new Twit({
  128. consumer_key: config.twitter_consumer_key,
  129. consumer_secret: config.twitter_consumer_secret,
  130. access_token: storage.data.token,
  131. access_token_secret: storage.data.token_secret,
  132. timeout_ms: 60*1000, // optional HTTP request timeout to apply to all requests
  133. strictSSL: true, // optional - requires SSL certificates to be valid
  134. })
  135. this.mastodon = new Mastodon({
  136. access_token: config.mastodon_token,
  137. timeout_ms: 60*1000, // optional HTTP request timeout to apply to all requests.
  138. api_url: `https://${config.instance}/api/v1/`, // optional, defaults to https://mastodon.social/api/v1/
  139. })
  140. setInterval(() => {
  141. this.mastodon.get('timelines/home', {}).then(resp => {
  142. let lastPostDateRecord = this.lastPostDate
  143. resp.data
  144. .filter(it => it.account.username === config.mastodon_user)
  145. .filter(it => Date.parse(it.created_at) > this.lastPostDate)
  146. .forEach((status) => {
  147. let date = Date.parse(status.created_at)
  148. if (date > lastPostDateRecord) lastPostDateRecord = date
  149. if (status.media_attachments.length > 0) {
  150. let downloads = status.media_attachments.map(media =>
  151. this.downloadFile(media.url, media.id)
  152. )
  153. Promise.all(downloads)
  154. .then(files => {
  155. this.twitterPostWithMedias(this.twitterFormat(status.content), files)
  156. .then(() => {
  157. files.forEach(file => {
  158. fs.unlink(file, (err) => {
  159. if (err) throw err
  160. console.log(`[INFO] Deleted ${file}`)
  161. })
  162. })
  163. }).catch(e => { console.error(e) })
  164. }).catch(err => { console.error(err) })
  165. } else {
  166. this.twitterPost(this.twitterFormat(status.content))
  167. }
  168. })
  169. this.lastPostDate = lastPostDateRecord
  170. })
  171. }, 10000)
  172. }
  173. }
  174. module.exports = bot