-
-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(syndicator-twitter): add twitter syndicator. fixes #307
- Loading branch information
1 parent
14824b5
commit b8122a3
Showing
11 changed files
with
1,154 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
# @indiekit/syndicator-twitter | ||
|
||
Syndicate IndieWeb content to Twitter. | ||
|
||
## Installation | ||
|
||
`npm i @indiekit/syndicator-twitter` | ||
|
||
## Usage | ||
|
||
```js | ||
const TwitterSyndicator = require('@indiekit/syndicator-twitter'); | ||
|
||
const twitter = new TwitterSyndicator({ | ||
// config options here | ||
}); | ||
``` | ||
|
||
## Options | ||
|
||
You can get your Twitter API keys from <https://developer.twitter.com/>. | ||
|
||
### `apiKey` | ||
|
||
Your Twitter API key. | ||
|
||
Type: `string`\ | ||
*Required* | ||
|
||
### `apiKeySecret` | ||
|
||
Your Twitter API secret key. | ||
|
||
Type: `string`\ | ||
*Required* | ||
|
||
### `accessToken` | ||
|
||
Your Twitter access token. | ||
|
||
Type: `string`\ | ||
*Required* | ||
|
||
### `accessTokenSecret` | ||
|
||
Your Twitter access token secret. | ||
|
||
Type: `string`\ | ||
*Required* | ||
|
||
### `user` | ||
|
||
Your Twitter username (without the `@`). | ||
|
||
Type: `string`\ | ||
*Required* | ||
|
||
### `checked` | ||
|
||
Tell a Micropub client whether this syndicator should be enabled by default. | ||
|
||
Type: `boolean`\ | ||
*Optional*, defaults to `false` |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import HttpError from 'http-errors'; | ||
import {fileURLToPath} from 'url'; | ||
import path from 'path'; | ||
import {twitter} from './lib/twitter.js'; | ||
|
||
export const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
|
||
const defaults = { | ||
checked: false | ||
}; | ||
|
||
export const TwitterSyndicator = class { | ||
constructor(options = {}) { | ||
this.id = 'twitter'; | ||
this.name = 'Twitter'; | ||
this.options = {...defaults, ...options}; | ||
} | ||
|
||
get assetsPath() { | ||
return path.join(__dirname, 'assets'); | ||
} | ||
|
||
get info() { | ||
const {checked, user} = this.options; | ||
|
||
return { | ||
checked, | ||
name: `${user} on Twitter`, | ||
uid: `https://twitter.com/${user}`, | ||
service: { | ||
name: 'Twitter', | ||
url: 'https://twitter.com/', | ||
photo: '/assets/twitter/icon.svg' | ||
}, | ||
user: { | ||
name: user, | ||
url: `https://twitter.com/${user}` | ||
} | ||
}; | ||
} | ||
|
||
get uid() { | ||
return this.info.uid; | ||
} | ||
|
||
async syndicate(postData) { | ||
if (!postData) { | ||
throw new Error('No post data given to syndicate'); | ||
} | ||
|
||
try { | ||
// Construct syndicated URL | ||
const syndicatedUrl = await twitter(this.options).post(postData.properties); | ||
|
||
// Ruturn successful syndication message | ||
return { | ||
location: syndicatedUrl, | ||
status: 200, | ||
json: { | ||
success: 'syndicate', | ||
success_description: `Post syndicated to ${syndicatedUrl}` | ||
} | ||
}; | ||
} catch (error) { | ||
throw new HttpError(error); | ||
} | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
/* eslint-disable camelcase */ | ||
import brevity from 'brevity'; | ||
import got from 'got'; | ||
import path from 'path'; | ||
import Twitter from 'twitter-lite'; | ||
|
||
export const twitter = options => ({ | ||
client: (subdomain = 'api') => new Twitter({ | ||
subdomain, | ||
consumer_key: options.apiKey, | ||
consumer_secret: options.apiKeySecret, | ||
access_token_key: options.accessToken, | ||
access_token_secret: options.accessTokenSecret | ||
}), | ||
|
||
/** | ||
* Test if string is a Twitter status URL | ||
* | ||
* @param {string} string URL | ||
* @returns {boolean} Twitter status URL? | ||
*/ | ||
isTweetUrl: string => { | ||
const parsedUrl = new URL(string); | ||
return parsedUrl.hostname === 'twitter.com'; | ||
}, | ||
|
||
/** | ||
* Get status ID from Twitter status URL | ||
* | ||
* @param {string} url Twitter status URL | ||
* @returns {string} Status ID | ||
*/ | ||
getStatusIdFromUrl: url => { | ||
const parsedUrl = new URL(url); | ||
const statusId = path.basename(parsedUrl.pathname); | ||
return statusId; | ||
}, | ||
|
||
/** | ||
* Post a like | ||
* | ||
* @param {string} tweetUrl URL of tweet to like | ||
* @returns {string} Twitter status URL | ||
*/ | ||
async postLike(tweetUrl) { | ||
try { | ||
const statusId = this.getStatusIdFromUrl(tweetUrl); | ||
const {user, id_str} = await this.client().post('favorites/create', {id: statusId}); | ||
const url = `https://twitter.com/${user.screen_name}/status/${id_str}`; | ||
return url; | ||
} catch (error) { | ||
const errorObject = error.errors ? error.errors[0] : error; | ||
throw new Error(errorObject.message); | ||
} | ||
}, | ||
|
||
/** | ||
* Post a retweet | ||
* | ||
* @param {string} tweetUrl URL of tweet to retweet | ||
* @returns {string} Twitter status URL | ||
*/ | ||
async postRetweet(tweetUrl) { | ||
try { | ||
const statusId = this.getStatusIdFromUrl(tweetUrl); | ||
const {user, id_str} = await this.client().post(`statuses/retweet/${statusId}`); | ||
const url = `https://twitter.com/${user.screen_name}/status/${id_str}`; | ||
return url; | ||
} catch (error) { | ||
const errorObject = error.errors ? error.errors[0] : error; | ||
throw new Error(errorObject.message); | ||
} | ||
}, | ||
|
||
/** | ||
* Post a status | ||
* | ||
* @param {object} parameters Status parameters | ||
* @returns {string} Twitter status URL | ||
*/ | ||
async postStatus(parameters) { | ||
try { | ||
const {user, id_str} = await this.client().post('statuses/update', parameters); | ||
const url = `https://twitter.com/${user.screen_name}/status/${id_str}`; | ||
return url; | ||
} catch (error) { | ||
const errorObject = error.errors ? error.errors[0] : error; | ||
throw new Error(errorObject.message); | ||
} | ||
}, | ||
|
||
/** | ||
* Upload media and return Twitter media id | ||
* | ||
* @param {string} url Media URL | ||
* @returns {string} Twitter media id | ||
*/ | ||
async uploadMedia(url) { | ||
if (typeof url !== 'string') { | ||
return; | ||
} | ||
|
||
try { | ||
const response = await got(url, {responseType: 'buffer'}); | ||
const buffer = Buffer.from(response.body).toString('base64'); | ||
const {media_id_string} = await this.client('upload').post('media/upload', {media_data: buffer}); | ||
return media_id_string; | ||
} catch (error) { | ||
const errorObject = error.errors ? error.errors[0] : error; | ||
throw new Error(errorObject.message); | ||
} | ||
}, | ||
|
||
/** | ||
* Get status parameters from given JF2 properties | ||
* | ||
* @param {object} properties A JF2 properties object | ||
* @returns {object} Status parameters | ||
*/ | ||
async createStatus(properties) { | ||
const parameters = {}; | ||
|
||
let status; | ||
|
||
if (properties['post-type'] === 'article') { | ||
status = `${properties.name}\n\n${properties.url}`; | ||
} else if (properties.name) { | ||
status = properties.name; | ||
} else if (properties.content && properties.content.text) { | ||
status = properties.content.text; | ||
} else if (properties.content) { | ||
status = properties.content; | ||
} | ||
|
||
// If repost of Twitter URL with content, create a quote tweet | ||
if (properties['post-type'] === 'repost') { | ||
status = `${properties.content}\n\n${properties['repost-of']}`; | ||
} | ||
|
||
// If post is in reply to a tweet, add respective parameter | ||
if (properties['in-reply-to']) { | ||
const replyTo = properties['in-reply-to']; | ||
if (replyTo.includes('twitter.com')) { | ||
const statusId = this.getStatusIdFromUrl(replyTo); | ||
parameters.in_reply_to_status_id = statusId; | ||
} | ||
} | ||
|
||
// Add location parameters | ||
if (properties.location) { | ||
parameters.lat = properties.location.properties.latitude; | ||
parameters.long = properties.location.properties.longitude; | ||
} | ||
|
||
// Add photos | ||
if (properties.photo) { | ||
const uploads = []; | ||
|
||
// Trim to 4 photos as Twitter doesn’t support more | ||
const photos = properties.photo.slice(0, 4); | ||
for await (const photo of photos) { | ||
uploads.push(this.uploadMedia(photo.url)); | ||
} | ||
|
||
const mediaIds = await Promise.all(uploads); | ||
|
||
parameters.media_ids = mediaIds.join(','); | ||
} | ||
|
||
// Truncate status if longer than 280 characters | ||
parameters.status = brevity.shorten( | ||
status, | ||
properties.url, | ||
false, // https://indieweb.org/permashortlink | ||
false, // https://indieweb.org/permashortcitation | ||
280 | ||
); | ||
|
||
return parameters; | ||
}, | ||
|
||
/** | ||
* Post to Twitter | ||
* | ||
* @param {object} properties JF2 properties object | ||
* @returns {string} URL of syndicated tweet | ||
*/ | ||
async post(properties) { | ||
if (properties['repost-of']) { | ||
// Syndicate repost of Twitter URL with content as a quote tweet | ||
if (this.isTweetUrl(properties['repost-of']) && properties.content) { | ||
const status = await this.createStatus(properties); | ||
return this.postStatus(status); | ||
} | ||
|
||
// Syndicate repost of Twitter URL as a retweet | ||
if (this.isTweetUrl(properties['repost-of'])) { | ||
return this.postRetweet(properties['repost-of']); | ||
} | ||
|
||
// Do not syndicate reposts of other URLs | ||
return false; | ||
} | ||
|
||
if (properties['like-of']) { | ||
// Syndicate like of Twitter URL as a like | ||
if (this.isTweetUrl(properties['like-of'])) { | ||
return this.postLike(properties['like-of']); | ||
} | ||
|
||
// Do not syndicate likes of other URLs | ||
return false; | ||
} | ||
|
||
const status = await this.createStatus(properties); | ||
if (status) { | ||
return this.postStatus(status); | ||
} | ||
} | ||
}); |
Oops, something went wrong.