├── .gitignore ├── index.d.ts ├── tsconfig.json ├── lambdas ├── twitter-schedule_delete.ts ├── common │ └── common.ts ├── twitter-schedule_put.ts ├── twitter-auth_post.ts ├── twitter-login_post.ts ├── twitter-upload_post.ts ├── twitter-schedule_post.ts ├── twitter-tweet_post.ts ├── twitter-search_get.ts ├── twitter-schedule_get.ts ├── twitter-feed_get.ts └── schedule-twitter.ts ├── .github └── workflows │ ├── main.yaml │ └── lambdas.yaml ├── src ├── TwitterLogo.svg ├── index.ts ├── TwitterFeed.tsx ├── ScheduledDashboard.tsx └── TweetOverlay.tsx ├── package.json ├── aws.tf └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | .env.local 4 | build 5 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const value: React.FunctionComponent>; 3 | export = value; 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/roamjs-scripts/default.tsconfig", 3 | "include": ["src", "lambdas", "components", "index.d.ts"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /lambdas/twitter-schedule_delete.ts: -------------------------------------------------------------------------------- 1 | import { awsGetRoamJSUser } from "roamjs-components/backend/getRoamJSUser"; 2 | import headers from "roamjs-components/backend/headers"; 3 | import { dynamo } from "./common/common"; 4 | 5 | export const handler = awsGetRoamJSUser<{ uuid: string }>((_, { uuid }) => 6 | dynamo 7 | .deleteItem({ 8 | TableName: "RoamJSSocial", 9 | Key: { uuid: { S: uuid } }, 10 | }) 11 | .promise() 12 | .then(() => ({ statusCode: 204, body: "", headers })) 13 | .catch((e) => ({ statusCode: 500, body: e.message, headers })) 14 | ); 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Extension 2 | on: 3 | push: 4 | branches: main 5 | paths: 6 | - "package.json" 7 | - "src/**" 8 | - ".github/workflows/main.yaml" 9 | 10 | env: 11 | API_URL: https://lambda.roamjs.com 12 | ROAMJS_DEVELOPER_TOKEN: ${{ secrets.ROAMJS_DEVELOPER_TOKEN }} 13 | ROAMJS_EMAIL: support@roamjs.com 14 | ROAMJS_EXTENSION_ID: twitter 15 | ROAMJS_RELEASE_TOKEN: ${{ secrets.ROAMJS_RELEASE_TOKEN }} 16 | 17 | jobs: 18 | deploy: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: install 23 | run: npm install 24 | - name: build 25 | run: npx roamjs-scripts build --depot 26 | - name: publish 27 | run: npx roamjs-scripts publish --depot 28 | -------------------------------------------------------------------------------- /lambdas/common/common.ts: -------------------------------------------------------------------------------- 1 | import OAuth from "oauth-1.0a"; 2 | import crypto from "crypto"; 3 | import AWS from "aws-sdk"; 4 | 5 | const credentials = { 6 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 7 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 8 | }; 9 | export const dynamo = new AWS.DynamoDB({ 10 | apiVersion: "2012-08-10", 11 | credentials, 12 | }); 13 | export const ses = new AWS.SES({ apiVersion: "2010-12-01", credentials }); 14 | export const s3 = new AWS.S3({ credentials }); 15 | 16 | export const twitterOAuth = new OAuth({ 17 | consumer: { 18 | key: process.env.TWITTER_CONSUMER_KEY || "", 19 | secret: process.env.TWITTER_CONSUMER_SECRET || "", 20 | }, 21 | signature_method: "HMAC-SHA1", 22 | hash_function(base_string, key) { 23 | return crypto.createHmac("sha1", key).update(base_string).digest("base64"); 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /.github/workflows/lambdas.yaml: -------------------------------------------------------------------------------- 1 | name: Push Lambdas 2 | on: 3 | push: 4 | branches: main 5 | paths: 6 | - "lambdas/**" 7 | - "package.json" 8 | - ".github/workflows/lambdas.yaml" 9 | 10 | env: 11 | AWS_ACCESS_KEY_ID: ${{ secrets.DEPLOY_AWS_ACCESS_KEY }} 12 | AWS_SECRET_ACCESS_KEY: ${{ secrets.DEPLOY_AWS_ACCESS_SECRET }} 13 | TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} 14 | TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} 15 | ROAMJS_DEVELOPER_TOKEN: ${{ secrets.ROAMJS_DEVELOPER_TOKEN }} 16 | ROAMJS_EMAIL: dvargas92495@gmail.com 17 | ROAMJS_EXTENSION_ID: twitter 18 | 19 | jobs: 20 | deploy: 21 | runs-on: ubuntu-18.04 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js 14.17.6 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: 14.17.6 28 | - name: install 29 | run: npm install 30 | - name: lambdas 31 | run: npm run lambdas 32 | -------------------------------------------------------------------------------- /lambdas/twitter-schedule_put.ts: -------------------------------------------------------------------------------- 1 | import { awsGetRoamJSUser } from "roamjs-components/backend/getRoamJSUser"; 2 | import headers from "roamjs-components/backend/headers"; 3 | import { dynamo, s3 } from "./common/common"; 4 | 5 | export const handler = awsGetRoamJSUser<{ 6 | scheduleDate: string; 7 | payload: string; 8 | uuid: string; 9 | }>((_, { scheduleDate, payload, uuid }) => { 10 | return dynamo 11 | .updateItem({ 12 | TableName: "RoamJSSocial", 13 | Key: { 14 | uuid: { S: uuid }, 15 | }, 16 | UpdateExpression: "SET #d = :d", 17 | ExpressionAttributeNames: { 18 | "#d": "date", 19 | }, 20 | ExpressionAttributeValues: { 21 | ":d": { S: scheduleDate }, 22 | }, 23 | }) 24 | .promise() 25 | .then(() => 26 | s3 27 | .upload({ 28 | Bucket: "roamjs-data", 29 | Body: payload, 30 | Key: `twitter/scheduled/${uuid}.json`, 31 | ContentType: "application/json", 32 | }) 33 | .promise() 34 | ) 35 | .then(() => ({ 36 | statusCode: 200, 37 | body: JSON.stringify({ success: true }), 38 | headers, 39 | })); 40 | }); 41 | -------------------------------------------------------------------------------- /src/TwitterLogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | -------------------------------------------------------------------------------- /lambdas/twitter-auth_post.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from "aws-lambda"; 2 | import axios from "axios"; 3 | import { twitterOAuth } from "./common/common"; 4 | import headers from "roamjs-components/backend/headers"; 5 | 6 | export const handler: APIGatewayProxyHandler = async (event) => { 7 | const data = JSON.parse(event.body || "{}"); 8 | const oauthHeaders = twitterOAuth.toHeader( 9 | twitterOAuth.authorize({ 10 | data, 11 | url: "https://api.twitter.com/oauth/access_token", 12 | method: "POST", 13 | }) 14 | ); 15 | 16 | return axios 17 | .post("https://api.twitter.com/oauth/access_token", data, { 18 | headers: oauthHeaders, 19 | }) 20 | .then((r) => { 21 | const parsedData = Object.fromEntries( 22 | r.data.split("&").map((s: string) => s.split("=")) 23 | ); 24 | const { oauth_token, oauth_token_secret, screen_name } = parsedData; 25 | return { 26 | statusCode: 200, 27 | body: JSON.stringify({ 28 | oauth_token, 29 | oauth_token_secret, 30 | label: screen_name, 31 | }), 32 | headers, 33 | }; 34 | }) 35 | .catch((e) => ({ 36 | statusCode: 500, 37 | body: JSON.stringify(e.response?.data || { message: e.message }), 38 | headers, 39 | })); 40 | }; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roamjs-twitter", 3 | "version": "1.0.1", 4 | "description": "Connects your Roam graph to Twitter!", 5 | "main": "out/index.js", 6 | "scripts": { 7 | "lambdas": "cross-env NODE_ENV=production roamjs-scripts lambdas", 8 | "prebuild:roam": "npm install", 9 | "build:roam": "roamjs-scripts build --depot", 10 | "dev": "roamjs-scripts dev --depot", 11 | "preserver": "roamjs-scripts lambdas --build", 12 | "server": "localhost-lambdas 3005", 13 | "start": "concurrently npm:dev npm:server" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/dvargas92495/roamjs-twitter.git" 18 | }, 19 | "author": "dvargas92495 ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/dvargas92495/roamjs-twitter/issues" 23 | }, 24 | "homepage": "https://github.com/dvargas92495/roamjs-twitter#readme", 25 | "devDependencies": { 26 | "@types/aws-lambda": "^8.10.83", 27 | "@types/twitter-text": "^3.1.1", 28 | "@types/uuid": "^8.3.0", 29 | "concurrently": "^7.4.0", 30 | "prettier": "^2.2.1" 31 | }, 32 | "dependencies": { 33 | "aws-sdk": "^2.854.0", 34 | "form-data": "^4.0.0", 35 | "oauth-1.0a": "^2.2.6", 36 | "react-tweet-embed": "^1.2.2", 37 | "roamjs-components": "^0.74.10", 38 | "roamjs-scripts": "^0.23.12", 39 | "twitter-text": "^3.1.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lambdas/twitter-login_post.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from "aws-lambda"; 2 | import axios from "axios"; 3 | import { twitterOAuth } from "./common/common"; 4 | import headers from "roamjs-components/backend/headers"; 5 | 6 | export const handler: APIGatewayProxyHandler = async (event) => { 7 | const { state } = JSON.parse(event.body); 8 | const data = { 9 | oauth_callback: `https://roamjs.com/oauth?auth=true&state=${state}`, 10 | }; 11 | const oauthHeaders = twitterOAuth.toHeader( 12 | twitterOAuth.authorize({ 13 | data, 14 | url: "https://api.twitter.com/oauth/request_token", 15 | method: "POST", 16 | }) 17 | ); 18 | 19 | return axios 20 | .post("https://api.twitter.com/oauth/request_token", data, { 21 | headers: oauthHeaders, 22 | }) 23 | .then((r) => { 24 | const parsedData = Object.fromEntries( 25 | r.data.split("&").map((s: string) => s.split("=")) 26 | ); 27 | if (parsedData.oauth_callback_confirmed) { 28 | return { 29 | statusCode: 200, 30 | body: JSON.stringify({ token: parsedData.oauth_token }), 31 | headers, 32 | }; 33 | } else { 34 | return { 35 | statusCode: 500, 36 | body: "Oauth Callback was not Confirmed", 37 | headers, 38 | }; 39 | } 40 | }) 41 | .catch((e) => ({ 42 | statusCode: 500, 43 | body: e.message, 44 | headers, 45 | })); 46 | }; 47 | -------------------------------------------------------------------------------- /lambdas/twitter-upload_post.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from "aws-lambda"; 2 | import axios from "axios"; 3 | import { twitterOAuth } from "./common/common"; 4 | import FormData from "form-data"; 5 | import querystring from "querystring"; 6 | import headers from "roamjs-components/backend/headers"; 7 | 8 | export const handler: APIGatewayProxyHandler = async (event) => { 9 | const { key, secret, params } = JSON.parse(event.body || "{}"); 10 | const isStatus = params.command === "STATUS"; 11 | const url = `https://upload.twitter.com/1.1/media/upload.json${ 12 | isStatus ? `?${querystring.stringify(params)}` : "" 13 | }`; 14 | const oauthHeaders = twitterOAuth.toHeader( 15 | twitterOAuth.authorize( 16 | { 17 | url, 18 | method: isStatus ? "GET" : "POST", 19 | }, 20 | { key, secret } 21 | ) 22 | ); 23 | const formData = new FormData(); 24 | Object.keys(params).forEach((k) => formData.append(k, params[k])); 25 | 26 | return (isStatus 27 | ? axios.get(url, { headers: oauthHeaders }) 28 | : axios.post(url, formData, { 29 | headers: { ...oauthHeaders, ...formData.getHeaders() }, 30 | }) 31 | ) 32 | .then((r) => ({ 33 | statusCode: 200, 34 | body: JSON.stringify(r.data), 35 | headers, 36 | })) 37 | .catch((e) => ({ 38 | statusCode: 500, 39 | body: JSON.stringify({ message: e.message, url, ...e.response?.data }), 40 | headers, 41 | })); 42 | }; 43 | -------------------------------------------------------------------------------- /lambdas/twitter-schedule_post.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from "uuid"; 2 | import { dynamo, s3 } from "./common/common"; 3 | import { APIGatewayProxyHandler } from "aws-lambda"; 4 | import { awsGetRoamJSUser } from "roamjs-components/backend/getRoamJSUser"; 5 | import headers from "roamjs-components/backend/headers"; 6 | 7 | export const handler: APIGatewayProxyHandler = (event, c, ca) => { 8 | const { scheduleDate, oauth, payload } = JSON.parse(event.body || "{}"); 9 | const uuid = v4(); 10 | const date = new Date().toJSON(); 11 | return awsGetRoamJSUser((user) => 12 | dynamo 13 | .putItem({ 14 | TableName: "RoamJSSocial", 15 | Item: { 16 | uuid: { S: uuid }, 17 | created: { S: date }, 18 | date: { S: scheduleDate }, 19 | oauth: { S: oauth }, 20 | blockUid: { S: JSON.parse(payload).blocks?.[0]?.uid }, 21 | status: { S: "PENDING" }, 22 | userId: { S: user.email }, 23 | channel: { 24 | S: 25 | process.env.NODE_ENV === "development" 26 | ? "development" 27 | : "twitter", 28 | }, 29 | }, 30 | }) 31 | .promise() 32 | .then(() => 33 | s3 34 | .upload({ 35 | Bucket: "roamjs-data", 36 | Body: payload, 37 | Key: `twitter/scheduled/${uuid}.json`, 38 | ContentType: "application/json", 39 | }) 40 | .promise() 41 | ) 42 | .then(() => ({ 43 | statusCode: 200, 44 | body: JSON.stringify({ id: uuid }), 45 | headers, 46 | })) 47 | )(event, c, ca); 48 | }; 49 | -------------------------------------------------------------------------------- /lambdas/twitter-tweet_post.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from "aws-lambda"; 2 | import axios from "axios"; 3 | import { twitterOAuth } from "./common/common"; 4 | import querystring from "querystring"; 5 | import headers from "roamjs-components/backend/headers"; 6 | 7 | export const handler: APIGatewayProxyHandler = async (event) => { 8 | const { 9 | key, 10 | secret, 11 | content, 12 | in_reply_to_status_id, 13 | auto_populate_reply_metadata, 14 | media_ids, 15 | } = JSON.parse(event.body || "{}"); 16 | const data = { 17 | status: content, 18 | ...(media_ids.length ? { media_ids: media_ids.join(",") } : {}), 19 | ...(auto_populate_reply_metadata 20 | ? { in_reply_to_status_id, auto_populate_reply_metadata } 21 | : {}), 22 | }; 23 | const url = `https://api.twitter.com/1.1/statuses/update.json?${querystring 24 | .stringify(data) 25 | .replace(/!/g, "%21") 26 | .replace(/'/g, "%27") 27 | .replace(/\(/g, "%28") 28 | .replace(/\)/g, "%29") 29 | .replace(/\*/g, "%2A")}`; 30 | const oauthHeaders = twitterOAuth.toHeader( 31 | twitterOAuth.authorize( 32 | { 33 | url, 34 | method: "POST", 35 | }, 36 | { key, secret } 37 | ) 38 | ); 39 | 40 | return axios 41 | .post( 42 | url, 43 | {}, 44 | { 45 | headers: oauthHeaders, 46 | } 47 | ) 48 | .then((r) => ({ 49 | statusCode: 200, 50 | body: JSON.stringify(r.data), 51 | headers, 52 | })) 53 | .catch((e) => ({ 54 | statusCode: 500, 55 | body: JSON.stringify({ message: e.message, url, ...e.response?.data }), 56 | headers, 57 | })); 58 | }; 59 | -------------------------------------------------------------------------------- /lambdas/twitter-search_get.ts: -------------------------------------------------------------------------------- 1 | import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; 2 | import axios from "axios"; 3 | import type { AxiosPromise } from "axios"; 4 | import headers from "roamjs-components/backend/headers"; 5 | 6 | export const userError = (body: string): APIGatewayProxyResult => ({ 7 | statusCode: 400, 8 | body, 9 | headers, 10 | }); 11 | 12 | export const wrapAxios = ( 13 | req: AxiosPromise> 14 | ): Promise => 15 | req 16 | .then((r) => ({ 17 | statusCode: 200, 18 | body: JSON.stringify(r.data), 19 | headers, 20 | })) 21 | .catch((e) => ({ 22 | statusCode: e.response?.status || 500, 23 | body: e.response?.data ? JSON.stringify(e.response.data) : e.message, 24 | headers, 25 | })); 26 | 27 | export const handler = async ( 28 | event: APIGatewayProxyEvent 29 | ): Promise => { 30 | const { username, query } = event.queryStringParameters; 31 | if (!username) { 32 | return userError("username is required"); 33 | } 34 | if (!query) { 35 | return userError("query is required"); 36 | } 37 | const twitterBearerTokenResponse = await wrapAxios( 38 | axios.post( 39 | `https://api.twitter.com/oauth2/token`, 40 | {}, 41 | { 42 | params: { 43 | grant_type: "client_credentials", 44 | }, 45 | auth: { 46 | username: process.env.TWITTER_CONSUMER_KEY, 47 | password: process.env.TWITTER_CONSUMER_SECRET, 48 | }, 49 | headers: { 50 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 51 | }, 52 | } 53 | ) 54 | ); 55 | 56 | const body = JSON.parse(twitterBearerTokenResponse.body); 57 | const twitterBearerToken = body.access_token; 58 | 59 | const opts = { 60 | headers: { 61 | Authorization: `Bearer ${twitterBearerToken}`, 62 | }, 63 | }; 64 | 65 | return wrapAxios( 66 | axios.get( 67 | `https://api.twitter.com/1.1/search/tweets.json?q=from%3A${username}%20${encodeURIComponent( 68 | query 69 | )}%20AND%20-filter:retweets`, 70 | opts 71 | ) 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /lambdas/twitter-schedule_get.ts: -------------------------------------------------------------------------------- 1 | import { dynamo } from "./common/common"; 2 | import { APIGatewayProxyHandler } from "aws-lambda"; 3 | import headers from "roamjs-components/backend/headers"; 4 | import { awsGetRoamJSUser } from "roamjs-components/backend/getRoamJSUser"; 5 | 6 | export const handler: APIGatewayProxyHandler = (event, c, ca) => { 7 | const { id } = event.queryStringParameters || {}; 8 | return awsGetRoamJSUser((user) => 9 | id 10 | ? dynamo 11 | .getItem({ 12 | TableName: "RoamJSSocial", 13 | Key: { uuid: { S: id } }, 14 | }) 15 | .promise() 16 | .then((r) => ({ 17 | statusCode: 200, 18 | body: JSON.stringify({ 19 | uuid: r.Item.uuid.S, 20 | blockUid: 21 | r.Item.blockUid?.S || 22 | JSON.parse(r.Item.payload.S).blocks?.[0]?.uid, 23 | createdDate: r.Item.created.S, 24 | scheduledDate: r.Item.date.S, 25 | status: r.Item.status.S, 26 | message: r.Item.message?.S, 27 | }), 28 | headers, 29 | })) 30 | : dynamo 31 | .query({ 32 | TableName: "RoamJSSocial", 33 | IndexName: "user-index", 34 | ExpressionAttributeNames: { 35 | "#u": "userId", 36 | "#c": "channel", 37 | }, 38 | ExpressionAttributeValues: { 39 | ":u": { S: user.email }, 40 | ":c": { 41 | S: 42 | process.env.NODE_ENV === "development" 43 | ? "development" 44 | : "twitter", 45 | }, 46 | }, 47 | KeyConditionExpression: "#u = :u AND #c = :c", 48 | }) 49 | .promise() 50 | .then(({ Items }) => ({ 51 | statusCode: 200, 52 | body: JSON.stringify({ 53 | scheduledTweets: (Items || []).map((item) => ({ 54 | uuid: item.uuid.S, 55 | blockUid: 56 | item.blockUid?.S || 57 | JSON.parse(item.payload.S).blocks?.[0]?.uid, 58 | createdDate: item.created.S, 59 | scheduledDate: item.date.S, 60 | status: item.status.S, 61 | message: item.message?.S, 62 | })), 63 | }), 64 | headers, 65 | })) 66 | )(event, c, ca); 67 | }; 68 | -------------------------------------------------------------------------------- /lambdas/twitter-feed_get.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from "aws-lambda"; 2 | import axios from "axios"; 3 | import isAfter from "date-fns/isAfter"; 4 | import isBefore from "date-fns/isBefore"; 5 | import { twitterOAuth } from "./common/common"; 6 | import headers from "roamjs-components/backend/headers"; 7 | 8 | export const handler: APIGatewayProxyHandler = async (event) => { 9 | const [key, secret] = ( 10 | event.headers.Authorization || event.headers.authorization 11 | ).split(":"); 12 | const { to, from } = event.queryStringParameters; 13 | const toDate = new Date(to); 14 | const fromDate = new Date(from); 15 | const getFavorites = async ({ 16 | maxId, 17 | }: { 18 | maxId?: string; 19 | }): Promise<{ id: string }[]> => { 20 | const url = `https://api.twitter.com/1.1/favorites/list.json?count=200${ 21 | maxId ? `&max_id=${maxId}` : "" 22 | }&tweet_mode=extended`; 23 | const oauthHeaders = twitterOAuth.toHeader( 24 | twitterOAuth.authorize( 25 | { 26 | url, 27 | method: "GET", 28 | }, 29 | { key, secret } 30 | ) 31 | ); 32 | return axios 33 | .get< 34 | { 35 | id_str: string; 36 | created_at: string; 37 | full_text: string; 38 | user: { name: string; screen_name: string }; 39 | }[] 40 | >(url, { 41 | headers: oauthHeaders, 42 | }) 43 | .then(async (r) => { 44 | const tweets = r.data 45 | .filter( 46 | (t) => 47 | !isBefore(new Date(t.created_at), fromDate) && 48 | !isAfter(new Date(t.created_at), toDate) 49 | ) 50 | .map((t) => ({ 51 | id: t.id_str, 52 | text: t.full_text, 53 | handle: t.user?.screen_name, 54 | author: t.user?.name, 55 | })); 56 | const oldestTweet = r.data.slice(-1)[0]; 57 | if ( 58 | r.data.length > 1 && 59 | isAfter(new Date(oldestTweet.created_at), fromDate) 60 | ) { 61 | return [ 62 | ...tweets, 63 | ...(await getFavorites({ maxId: oldestTweet.id_str })), 64 | ]; 65 | } else { 66 | return tweets; 67 | } 68 | }); 69 | }; 70 | 71 | return getFavorites({}) 72 | .then((tweets) => ({ 73 | statusCode: 200, 74 | body: JSON.stringify({ 75 | tweets, 76 | }), 77 | headers, 78 | })) 79 | .catch((e) => ({ 80 | statusCode: 500, 81 | body: JSON.stringify({ message: e.message, ...e.response?.data }), 82 | headers, 83 | })); 84 | }; 85 | -------------------------------------------------------------------------------- /aws.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "remote" { 3 | hostname = "app.terraform.io" 4 | organization = "VargasArts" 5 | workspaces { 6 | prefix = "roam-service-social" 7 | } 8 | } 9 | required_providers { 10 | github = { 11 | source = "integrations/github" 12 | version = "4.2.0" 13 | } 14 | } 15 | } 16 | 17 | variable "aws_access_token" { 18 | type = string 19 | } 20 | 21 | variable "aws_secret_token" { 22 | type = string 23 | } 24 | 25 | variable "twitter_consumer_key" { 26 | type = string 27 | } 28 | 29 | variable "twitter_consumer_secret" { 30 | type = string 31 | } 32 | 33 | variable "developer_token" { 34 | type = string 35 | } 36 | 37 | variable "github_token" { 38 | type = string 39 | } 40 | 41 | 42 | provider "aws" { 43 | region = "us-east-1" 44 | access_key = var.aws_access_token 45 | secret_key = var.aws_secret_token 46 | } 47 | 48 | module "aws_cron_job" { 49 | source = "dvargas92495/cron-job/aws" 50 | 51 | rule_name = "RoamJS" 52 | schedule = "rate(1 minute)" 53 | lambdas = [ 54 | "schedule-twitter" 55 | ] 56 | tags = { 57 | Application = "Roam JS Extensions" 58 | } 59 | } 60 | 61 | resource "aws_dynamodb_table" "social-messages" { 62 | name = "RoamJSSocial" 63 | billing_mode = "PAY_PER_REQUEST" 64 | hash_key = "uuid" 65 | 66 | attribute { 67 | name = "uuid" 68 | type = "S" 69 | } 70 | 71 | attribute { 72 | name = "date" 73 | type = "S" 74 | } 75 | 76 | attribute { 77 | name = "channel" 78 | type = "S" 79 | } 80 | 81 | attribute { 82 | name = "userId" 83 | type = "S" 84 | } 85 | 86 | global_secondary_index { 87 | hash_key = "channel" 88 | range_key = "date" 89 | name = "primary-index" 90 | non_key_attributes = [] 91 | projection_type = "ALL" 92 | read_capacity = 0 93 | write_capacity = 0 94 | } 95 | 96 | global_secondary_index { 97 | hash_key = "userId" 98 | range_key = "channel" 99 | name = "user-index" 100 | non_key_attributes = [] 101 | projection_type = "ALL" 102 | read_capacity = 0 103 | write_capacity = 0 104 | } 105 | 106 | tags = { 107 | Application = "Roam JS Extensions" 108 | } 109 | } 110 | 111 | module "roamjs_lambda" { 112 | source = "dvargas92495/lambda/roamjs" 113 | providers = { 114 | aws = aws 115 | github = github 116 | } 117 | 118 | name = "twitter" 119 | lambdas = [ 120 | { 121 | path = "twitter-auth", 122 | method = "post" 123 | }, 124 | { 125 | path = "twitter-feed", 126 | method = "get" 127 | }, 128 | { 129 | path = "twitter-login", 130 | method = "post" 131 | }, 132 | { 133 | path = "twitter-schedule", 134 | method = "get" 135 | }, 136 | { 137 | path = "twitter-schedule", 138 | method = "post" 139 | }, 140 | { 141 | path = "twitter-search", 142 | method = "get" 143 | }, 144 | { 145 | path = "twitter-tweet", 146 | method = "post" 147 | }, 148 | { 149 | path = "twitter-upload", 150 | method = "post" 151 | }, 152 | { 153 | path = "twitter-schedule", 154 | method = "put" 155 | }, 156 | { 157 | path = "twitter-schedule", 158 | method = "delete" 159 | }, 160 | ] 161 | aws_access_token = var.aws_access_token 162 | aws_secret_token = var.aws_secret_token 163 | github_token = var.github_token 164 | developer_token = var.developer_token 165 | } 166 | 167 | provider "github" { 168 | owner = "dvargas92495" 169 | token = var.github_token 170 | } 171 | 172 | resource "github_actions_secret" "twitter_consumer_key" { 173 | repository = "roamjs-twitter" 174 | secret_name = "TWITTER_CONSUMER_KEY" 175 | plaintext_value = var.twitter_consumer_key 176 | } 177 | 178 | resource "github_actions_secret" "twitter_consumer_secret" { 179 | repository = "roamjs-twitter" 180 | secret_name = "TWITTER_CONSUMER_SECRET" 181 | plaintext_value = var.twitter_consumer_secret 182 | } 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter 2 | 3 | Connects your Roam graph to Twitter! 4 | 5 | ## Usage 6 | The RoamJS Twitter extension allows you to use your Roam graph as a client to your Twitter account! Included in this extension is the ability to: 7 | 1. Send Tweets 8 | 2. Schedule Tweets 9 | 3. Import Tweets 10 | 11 | ## Sending Tweets 12 | In the Roam Depot Settings, a log in with Twitter button will be rendered on the configuration screen. Clicking the button will create a popup window, prompting you to authorize RoamJS to be able to send tweets on your behalf. You may need to allow popups from Roam for this to work. 13 | 14 | To send a tweet, create a `{{tweet}}` button and nest the content of the tweet as a child. 15 | 16 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2Fn5epsIriSq.png?alt=media&token=3c9d3bab-827f-4b1f-868c-8f3fbe935b9f) 17 | 18 | The button will be replaced with a Twitter icon. Clicking the icon will render an overlay with a "Send Tweet" button. 19 | 20 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FG7_KXjVVW6.png?alt=media&token=860e48fe-40ce-45f7-bb4f-5e5e0f96c1e5) 21 | 22 | Clicking "Send Tweet" will send the first child as the content of the Tweet! 23 | If the tweet button has multiple children, clicking "Send Tweet" will send each block as a tweet as part of a single Tweet thread. 24 | 25 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2Fyg_J3W5Gg_.png?alt=media&token=d923e6f8-31fc-4a5d-ab59-48c2a5912db3) 26 | 27 | Images, gifs, or videos in the Roam Block **inline with the rest of the tweet**, will be uploaded as media embedded in the tweet! The `![](url)` block text will be stripped from the tweet content. Up to four images, one gif, or one video are allowed by Twitter. 28 | 29 | ### After Sending 30 | 31 | There are various behaviours you could configure to occur after tweets are successfully sent. 32 | 33 | It could be useful to denote which blocks in Roam have already been sent by moving them to another page or block reference after sending. On Roam Depot Settings, you could add a block reference to the `Sent Tweets Parent` field to denote where your tweet blocks in Roam should move to after they have been successfully sent 34 | 35 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FcvkxdLBNN7.png?alt=media&token=57ec2385-fbbd-4cbd-bc77-105bace9c016) 36 | 37 | This will move all the blocks sent as children of this block upon sending. 38 | 39 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FZoIw9xhIc-.png?alt=media&token=cd839eea-3ac9-48f7-aec4-bd3976e11fe6) 40 | 41 | The label each Sent tweet thread uses could be configured with the `Sent Tweets Label` field. It supports the following placeholders: 42 | - `{now}` - Replaces with the current time 43 | 44 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FLRfaFOan-u.png?alt=media&token=354f479e-69d6-487f-9d5c-da06bff30c6f) 45 | 46 | Instead of moving blocks to a configured destination, tweeted blocks could instead be edited upon sending. In the Roam Depot Settings, editing the `Append After Tweet` field will specify text that will get added to the end of the tweet after sending. It supports the following placeholders: 47 | - `{link}` - The link of the published tweet. 48 | 49 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FZV_j_jY9H5.png?alt=media&token=d1d8622a-d6e8-44e1-aecb-d3887fb96851) 50 | 51 | Instead of appending text to individual tweet blocks, you could append text to the parent block of the tweet thread. This could be configured in the `Append To Parent` field in Roam Depot Settings. It supports the following placeholders: 52 | - `{link}` - The link of the first tweet of the thread 53 | - `{now}` - The time the tweet was successfully sent. 54 | 55 | ### Demo 56 | 57 | 58 | 59 | [View on Loom](https://www.loom.com/share/59efa05227f042258dee87bc0d7387e2) 60 | 61 | ## Scheduling 62 | 63 | With this feature enabled, you could schedule Tweets to be sent at a later date directly from within Roam. You should see a new option to schedule a tweet: 64 | 65 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FmwKr63DHs4.png?alt=media&token=736e1395-d6bd-491b-8413-52b61caf01b0) 66 | 67 | This will store your tweet thread in RoamJS to be sent at the time you specify. To view all of your current tweet threads, enter `Open Scheduled Tweets` from the Roam Command Palette: 68 | 69 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FhF2CrfFnqQ.png?alt=media&token=a2bc627b-ed78-40b0-9fa0-6cd407408830) 70 | 71 | For each scheduled tweet, you will see a clickable block reference pointing to the source tweet, the time you created the schedule, the time you scheduled the tweet for, and the current status. There are three statuses: 72 | - `SUCCESS` - Your scheduled tweet was successfully sent and clicking this status will take you to the link on Twitter 73 | - `PENDING` - Your tweet is still scheduled to be sent. 74 | - `FAILED` - Your tweet failed to send. Please contact support@roamjs.com for help with this issue. 75 | 76 | ### Demo 77 | 78 | 79 | [View on Loom](https://www.loom.com/share/dd902ccc4d194319aeac24e8ddbe5499) 80 | 81 | ## Twitter Feed 82 | You could configure the extension to show a feed of all tweets you liked the previous day upon opening your daily notes page. 83 | On your Roam Depot Settings, toggle on the `Feed Enabled` switch. Now navigate to the daily notes page or to today's page. A dialog will appear and show you a feed of all the tweets that were published yesterday and that you liked. 84 | 85 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FJf6chBjigi.png?alt=media&token=ffe896c9-aefc-45b9-bf11-6da2408cec6d) 86 | 87 | Clicking "Import" will add a `#[[Twitter Feed]]` tag to your Daily Notes page with links to all the tweets nested below it. 88 | 89 | By default, the Twitter feed only appears on the current daily notes page and log. You can configure the feed to appear on any daily notes page by toggling on the `Any Day` flag from your Roam Depot Settings. 90 | 91 | By default, the Twitter feed queries the previous day's likes relative to the current daily note page, as it's meant to review a full day's of liked tweets. You can configure the feed to show the current day's tweets by toggling on the `Today` flag in your Roam Depot Settings. 92 | 93 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2F6WG01-GPas.png?alt=media&token=10bc191c-bc2e-4e38-8e93-11f687ef1f33) 94 | 95 | By default, the tweets are imported at the top of the page. Toggle on the `Append Feed to Bottom` from your Roam Depot Settings to import the tweets to the bottom of the page. 96 | 97 | By default, the Twitter feed just outputs links to the tweets into the daily note page. To customize the format, edit the `Feed Import Format` field from your Roam Depot Settings. There are certain placeholders that will get replaced in the format: 98 | - `{text}` - The text of the tweet 99 | - `{link}` - The link to the tweet 100 | - `{handle}` - The twitter handle of the user 101 | - `{author}` - The name of the user on Twitter 102 | 103 | ## Searching Tweets 104 | In any page, create a `Twitter References` button by typing in `{{twitter references}}` (case-insensitive) in a block. Upon clicking the button, the extension will clear the button and fill the page in with the tweets where you've mentioned that page title. So, if you've tweeted about `books` a lot on twitter, you can head over to the `books` page on roam, and then pull all your tweets about `books`! 105 | 106 | One caveat is that this can only pull tweets made in the last 7 days. 107 | 108 | ## Removing Wikilinks 109 | The `Remove Wikilinks` button will strip square brackets `[[` and `#` from wikilinked pages. 110 | 111 | `[[thisPage]]` and `#[[thisPage]]` will become `thisPage` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import addStyle from "roamjs-components/dom/addStyle"; 2 | import createButtonObserver from "roamjs-components/dom/createButtonObserver"; 3 | import createHTMLObserver from "roamjs-components/dom/createHTMLObserver"; 4 | import genericError from "roamjs-components/dom/genericError"; 5 | import getParentUidByBlockUid from "roamjs-components/queries/getParentUidByBlockUid"; 6 | import getUidsFromButton from "roamjs-components/dom/getUidsFromButton"; 7 | import getUids from "roamjs-components/dom/getUids"; 8 | import getPageTitleByHtmlElement from "roamjs-components/dom/getPageTitleByHtmlElement"; 9 | import runExtension from "roamjs-components/util/runExtension"; 10 | import { render } from "./TweetOverlay"; 11 | import loadTwitterFeed from "./TwitterFeed"; 12 | import updateBlock from "roamjs-components/writes/updateBlock"; 13 | import TwitterLogo from "./TwitterLogo.svg"; 14 | import loadTwitterScheduling from "./ScheduledDashboard"; 15 | import createBlock from "roamjs-components/writes/createBlock"; 16 | import getOrderByBlockUid from "roamjs-components/queries/getOrderByBlockUid"; 17 | import apiGet from "roamjs-components/util/apiGet"; 18 | import React from "react"; 19 | import OauthPanel from "roamjs-components/components/OauthPanel"; 20 | import apiPost from "roamjs-components/util/apiPost"; 21 | import { addTokenDialogCommand } from "roamjs-components/components/TokenDialog"; 22 | 23 | const TWITTER_REFERENCES_COMMAND = "twitter-references"; 24 | 25 | const twitterReferencesListener = async ( 26 | _: { 27 | [key: string]: string; 28 | }, 29 | blockUid: string 30 | ) => { 31 | const pageTitle = getPageTitleByHtmlElement(document.activeElement) 32 | .textContent; 33 | 34 | const twitterSearch = apiGet<{ statuses: { id_str: string }[] }>({ 35 | path: `twitter-search`, 36 | data: { query: pageTitle }, 37 | }); 38 | 39 | twitterSearch 40 | .then(async (response) => { 41 | const { statuses } = response; 42 | const count = statuses.length; 43 | if (count === 0) { 44 | return window.roamAlphaAPI.updateBlock({ 45 | block: { 46 | string: "No tweets found!", 47 | uid: blockUid, 48 | }, 49 | }); 50 | } 51 | const bullets = statuses.map( 52 | (i: { id_str: string }) => 53 | `https://twitter.com/i/web/status/${i.id_str}` 54 | ); 55 | const order = getOrderByBlockUid(blockUid); 56 | const parentUid = getParentUidByBlockUid(blockUid); 57 | return Promise.all([ 58 | updateBlock({ uid: blockUid, text: bullets[0] }), 59 | ...bullets.slice(1).map((text, i) => 60 | createBlock({ 61 | parentUid, 62 | order: order + i + 1, 63 | node: { text }, 64 | }) 65 | ), 66 | ]); 67 | }) 68 | .catch(genericError); 69 | }; 70 | 71 | export default runExtension({ 72 | migratedTo: "Twitter", 73 | run: async (args) => { 74 | addStyle(`div.roamjs-twitter-count { 75 | position: relative; 76 | } 77 | 78 | .roamjs-twitter-feed-embed { 79 | display: inline-block; 80 | vertical-align: middle; 81 | } 82 | 83 | .roamjs-datepicker { 84 | background: transparent; 85 | align-self: center; 86 | } 87 | 88 | textarea:focus { 89 | outline: none; 90 | outline-offset: 0; 91 | } 92 | 93 | div:focus { 94 | outline: none; 95 | outline-offset: 0; 96 | }`); 97 | const toggleTwitterFeed = loadTwitterFeed(args); 98 | const toggleTwitterScheduling = loadTwitterScheduling(args); 99 | args.extensionAPI.settings.panel.create({ 100 | tabTitle: "Twitter", 101 | settings: [ 102 | { 103 | id: "oauth", 104 | name: "Log In", 105 | description: "Log into Twitter to connect your account to Roam!", 106 | action: { 107 | type: "reactComponent", 108 | component: () => 109 | React.createElement(OauthPanel, { 110 | service: "twitter", 111 | getPopoutUrl: (state: string): Promise => 112 | apiPost<{ token: string }>({ 113 | path: `twitter-login`, 114 | data: { state }, 115 | }).then( 116 | (r) => 117 | `https://api.twitter.com/oauth/authenticate?oauth_token=${r.token}` 118 | ), 119 | getAuthData: (data: string): Promise> => 120 | apiPost({ 121 | path: `twitter-auth`, 122 | data: JSON.parse(data), 123 | }), 124 | ServiceIcon: TwitterLogo, 125 | }), 126 | }, 127 | }, 128 | { 129 | id: "sent", 130 | action: { type: "input", placeholder: "((abcdefghi))" }, 131 | name: "Sent Tweets Parent", 132 | description: "Block reference to move sent tweets under.", 133 | }, 134 | { 135 | id: "label", 136 | action: { type: "input", placeholder: "((abcdefghi))" }, 137 | name: "Sent Tweets Label", 138 | description: 139 | "The label of the block that will be the parent of sent tweets", 140 | }, 141 | { 142 | id: "append-text", 143 | action: { type: "input", placeholder: "#tweeted" }, 144 | name: "Append After Tweet", 145 | description: "Text to append at the end of a sent tweet block", 146 | }, 147 | { 148 | id: "append-parent", 149 | action: { type: "input", placeholder: "#tweeted" }, 150 | name: "Append To Parent", 151 | description: 152 | "Text to append at the end of the parent block that sent the tweet thread", 153 | }, 154 | { 155 | action: { 156 | type: "switch", 157 | onChange: (e) => toggleTwitterFeed(e.target.checked), 158 | }, 159 | id: "feed-enabled", 160 | name: "Feed Enabled", 161 | description: 162 | "Whether or not to enable the Twitter feed displaying liked tweets from the last day", 163 | }, 164 | { 165 | action: { type: "switch" }, 166 | id: "any-day", 167 | name: "Feed on any day", 168 | description: 169 | "Whether or not the twitter feed should appear any time you appear on a daily note page", 170 | }, 171 | { 172 | action: { type: "switch" }, 173 | id: "bottom", 174 | name: "Append Feed to Bottom", 175 | description: 176 | "Whether to import today's tweets to the top or bottom of the daily note page", 177 | }, 178 | { 179 | action: { type: "input", placeholder: "{link}" }, 180 | id: "feed-format", 181 | name: "Feed Import Format", 182 | description: 183 | "The format each tweet will use when imported to the daily note page.", 184 | }, 185 | { 186 | action: { type: "switch" }, 187 | id: "today", 188 | name: "Feed Same Day", 189 | description: 190 | "Whether to query tweets liked on the same day of the Daily Note Page instead of the previous day.", 191 | }, 192 | { 193 | action: { 194 | type: "switch", 195 | onChange: (e) => toggleTwitterScheduling(e.target.checked), 196 | }, 197 | id: "scheduling-enabled", 198 | name: "Scheduling Enabled", 199 | description: "Whether or not the scheduling features are enabled", 200 | }, 201 | { 202 | action: { type: "switch" }, 203 | id: "mark-blocks", 204 | name: "Mark Scheduled Blocks", 205 | description: 206 | "Whether to mark blocks in your graph with an icon that shows they are already scheduled. Requires refreshing to take effect.", 207 | }, 208 | { 209 | action: { type: "switch" }, 210 | id: "parse-tags", 211 | name: "Remove Wikilinks", 212 | description: 213 | "Whether or not to remove wikilinks ([[thisPage]] and #[[thisPage]]) from tweets before sending", 214 | }, 215 | ], 216 | }); 217 | 218 | createButtonObserver({ 219 | shortcut: "tweet", 220 | attribute: "write-tweet", 221 | render: (b: HTMLButtonElement) => { 222 | const { blockUid } = getUidsFromButton(b); 223 | render({ 224 | parent: b.parentElement, 225 | blockUid, 226 | extensionAPI: args.extensionAPI, 227 | }); 228 | }, 229 | }); 230 | 231 | createHTMLObserver({ 232 | className: "twitter-tweet", 233 | tag: "DIV", 234 | callback: (d: HTMLDivElement) => { 235 | if (!d.hasAttribute("data-roamjs-twitter-reply")) { 236 | d.setAttribute("data-roamjs-twitter-reply", "true"); 237 | const block = d.closest(".roam-block") as HTMLDivElement; 238 | const sub = block.getElementsByTagName("sub")[0]; 239 | const tweetMatch = /\/([a-zA-Z0-9_]{1,15})\/status\/([0-9]*)\??/.exec( 240 | sub?.innerText 241 | ); 242 | const { blockUid } = getUids(block); 243 | const span = document.createElement("span"); 244 | d.appendChild(span); 245 | render({ 246 | parent: span, 247 | blockUid, 248 | tweetId: tweetMatch?.[2], 249 | extensionAPI: args.extensionAPI, 250 | }); 251 | } 252 | }, 253 | }); 254 | 255 | createButtonObserver({ 256 | attribute: TWITTER_REFERENCES_COMMAND, 257 | render: (b) => { 258 | b.onclick = () => 259 | twitterReferencesListener({}, getUidsFromButton(b).blockUid); 260 | }, 261 | }); 262 | 263 | addTokenDialogCommand(); 264 | if (args.extensionAPI.settings.get("scheduling-enabled")) 265 | toggleTwitterScheduling(true); 266 | return () => { 267 | toggleTwitterScheduling(false); 268 | }; 269 | }, 270 | }); 271 | -------------------------------------------------------------------------------- /src/TwitterFeed.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Checkbox, 4 | Classes, 5 | Dialog, 6 | Intent, 7 | Spinner, 8 | } from "@blueprintjs/core"; 9 | import React, { useCallback, useEffect, useMemo, useState } from "react"; 10 | import ReactDOM from "react-dom"; 11 | import createBlock from "roamjs-components/writes/createBlock"; 12 | import getChildrenLengthByPageUid from "roamjs-components/queries/getChildrenLengthByPageUid"; 13 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 14 | import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; 15 | import getOauth from "roamjs-components/util/getOauth"; 16 | import getOauthAccounts from "roamjs-components/util/getOauthAccounts"; 17 | import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; 18 | import subDays from "date-fns/subDays"; 19 | import startOfDay from "date-fns/startOfDay"; 20 | import endOfDay from "date-fns/endOfDay"; 21 | import TweetEmbed from "react-tweet-embed"; 22 | import type { OnloadArgs } from "roamjs-components/types/native"; 23 | import isTagOnPage from "roamjs-components/queries/isTagOnPage"; 24 | import parseRoamDateUid from "roamjs-components/date/parseRoamDateUid"; 25 | import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; 26 | import createPageTitleObserver from "roamjs-components/dom/createPageTitleObserver"; 27 | import getCurrentPageUid from "roamjs-components/dom/getCurrentPageUid"; 28 | import getRenderRoot from "roamjs-components/util/getRenderRoot"; 29 | import apiGet from "roamjs-components/util/apiGet"; 30 | 31 | type Tweet = { 32 | id: string; 33 | text: string; 34 | handle: string; 35 | author: string; 36 | checked: boolean; 37 | }; 38 | 39 | const getOrder = (parentUid: string) => { 40 | const tree = getBasicTreeByParentUid( 41 | getPageUidByPageTitle("roam/js/twitter") 42 | ); 43 | const isBottom = tree 44 | .find((t) => /feed/i.test(t.text)) 45 | ?.children?.some?.((t) => /bottom/i.test(t.text)); 46 | return isBottom ? getChildrenLengthByPageUid(parentUid) : 0; 47 | }; 48 | 49 | const TweetLabel = ({ id }: { id: string }) => { 50 | const [loaded, setLoaded] = useState(false); 51 | return ( 52 | <> 53 | {!loaded && } 54 | setLoaded(true)} 63 | /> 64 | 65 | ); 66 | }; 67 | 68 | const TwitterFeed = ({ 69 | title, 70 | format, 71 | isToday, 72 | }: { 73 | title: string; 74 | format: string; 75 | isToday: boolean; 76 | }): React.ReactElement => { 77 | const date = useMemo(() => window.roamAlphaAPI.util.pageTitleToDate(title), [ 78 | title, 79 | ]); 80 | const dayToQuery = useMemo(() => (isToday ? date : subDays(date, 1)), [ 81 | date, 82 | isToday, 83 | ]); 84 | const roamDate = useMemo( 85 | () => window.roamAlphaAPI.util.dateToPageTitle(dayToQuery), 86 | [dayToQuery] 87 | ); 88 | const [tweets, setTweets] = useState([]); 89 | const [loading, setLoading] = useState(true); 90 | const [error, setError] = useState(""); 91 | const accounts = useMemo(() => getOauthAccounts("twitter"), []); 92 | const [activeAccount, setActiveAccount] = useState(accounts[0]); 93 | const onClose = useCallback(() => { 94 | ReactDOM.unmountComponentAtNode( 95 | document.getElementById("roamjs-twitter-feed") 96 | ); 97 | }, [tweets]); 98 | const onCancel = useCallback(() => { 99 | const parentUid = window.roamAlphaAPI.util.dateToPageUid(date); 100 | createBlock({ 101 | parentUid, 102 | order: getOrder(parentUid), 103 | node: { 104 | text: "#[[Twitter Feed]]", 105 | children: [ 106 | { 107 | text: "Cancelled", 108 | children: [], 109 | }, 110 | ], 111 | }, 112 | }); 113 | onClose(); 114 | }, [onClose, date, title]); 115 | useEffect(() => { 116 | setLoading(true); 117 | setTweets([]); 118 | setError(""); 119 | const oauth = getOauth("twitter", activeAccount); 120 | if (oauth === "{}") { 121 | setError( 122 | "Need to log in with Twitter to use Daily Twitter Feed! Head to roam/js/twitter page to log in." 123 | ); 124 | return; 125 | } 126 | const { oauth_token: key, oauth_token_secret: secret } = JSON.parse(oauth); 127 | apiGet<{ tweets: Omit[] }>({ 128 | path: `twitter-feed`, 129 | data: { 130 | from: startOfDay(dayToQuery).toJSON(), 131 | to: endOfDay(dayToQuery).toJSON(), 132 | }, 133 | authorization: `${key}:${secret}`, 134 | }) 135 | .then((r) => { 136 | setTweets(r.tweets.map((t) => ({ ...t, checked: true }))); 137 | }) 138 | .catch((r) => setError(r.response?.data || r.message)) 139 | .finally(() => setLoading(false)); 140 | }, [setTweets, dayToQuery, activeAccount]); 141 | const onClick = useCallback(() => { 142 | createBlock({ 143 | parentUid: window.roamAlphaAPI.util.dateToPageUid(date), 144 | order: getOrder(title), 145 | node: { 146 | text: "#[[Twitter Feed]]", 147 | children: tweets 148 | .filter(({ checked }) => checked) 149 | .map((t) => ({ 150 | text: format 151 | .replace(/{link}/g, `https://twitter.com/i/web/status/${t.id}`) 152 | .replace(/{text}/g, t.text) 153 | .replace(/{handle}/g, t.handle) 154 | .replace(/{author}/g, t.author), 155 | })), 156 | }, 157 | }); 158 | onClose(); 159 | }, [tweets, onClose, title, date]); 160 | return ( 161 | 168 |
169 | {loading ? ( 170 | 171 | ) : error ? ( 172 | {error} 173 | ) : ( 174 |
182 | {tweets.map((tweet) => ( 183 | ) => 187 | setTweets( 188 | tweets.map((t) => 189 | t.id === tweet.id 190 | ? { 191 | ...t, 192 | checked: (e.target as HTMLInputElement).checked, 193 | } 194 | : t 195 | ) 196 | ) 197 | } 198 | > 199 | 200 | 201 | ))} 202 | {!tweets.length && No tweets liked.} 203 |
204 | )} 205 |
206 |
207 |
214 | {accounts.length > 1 && ( 215 | setActiveAccount(a)} 219 | disabled={loading} 220 | /> 221 | )} 222 | 230 |
231 |
232 |
233 | ); 234 | }; 235 | 236 | export const render = ( 237 | parent: HTMLDivElement, 238 | props: Parameters[0] 239 | ): void => ReactDOM.render(, parent); 240 | 241 | const loadTwitterFeed = (args: OnloadArgs) => { 242 | const unloads = new Set<() => void>(); 243 | return (enabled: boolean) => { 244 | if (enabled) { 245 | const isAnyDay = !!args.extensionAPI.settings.get("any-day"); 246 | const isToday = !!args.extensionAPI.settings.get("today"); 247 | const format = 248 | (args.extensionAPI.settings.get("feed-format") as string) || "{link}"; 249 | const callback = ({ title, d }: { d: HTMLDivElement; title: string }) => { 250 | if (!isTagOnPage({ tag: "Twitter Feed", title })) { 251 | const parent = document.createElement("div"); 252 | parent.id = "roamjs-twitter-feed"; 253 | d.firstElementChild.insertBefore( 254 | parent, 255 | d.firstElementChild.firstElementChild.nextElementSibling 256 | ); 257 | render(parent, { title, format, isToday }); 258 | } 259 | }; 260 | if (isAnyDay) { 261 | const listener = (e?: HashChangeEvent) => { 262 | const d = document.getElementsByClassName( 263 | "roam-article" 264 | )[0] as HTMLDivElement; 265 | if (d) { 266 | const url = e?.newURL || window.location.href; 267 | const uid = url.match(/\/page\/(.*)$/)?.[1] || ""; 268 | const attribute = `data-roamjs-${uid}`; 269 | if (!isNaN(parseRoamDateUid(uid).valueOf())) { 270 | // React's rerender crushes the old article/heading 271 | setTimeout(() => { 272 | if (!d.hasAttribute(attribute)) { 273 | d.setAttribute(attribute, "true"); 274 | callback({ 275 | d: document.getElementsByClassName( 276 | "roam-article" 277 | )[0] as HTMLDivElement, 278 | title: getPageTitleByPageUid(uid), 279 | }); 280 | } 281 | }, 1); 282 | } else { 283 | d.removeAttribute(attribute); 284 | } 285 | } 286 | }; 287 | window.addEventListener("hashchange", listener); 288 | } else { 289 | const title = window.roamAlphaAPI.util.dateToPageTitle(new Date()); 290 | createPageTitleObserver({ 291 | title, 292 | log: true, 293 | callback: (d: HTMLDivElement) => callback({ d, title }), 294 | }); 295 | } 296 | window.roamAlphaAPI.ui.commandPalette.addCommand({ 297 | label: "Open Twitter Feed", 298 | callback: () => { 299 | const title = getPageTitleByPageUid(getCurrentPageUid()); 300 | const root = getRenderRoot("twitter-feed"); 301 | root.id = root.id.replace(/-root$/, ""); 302 | render(root, { format, title, isToday }); 303 | }, 304 | }); 305 | } else { 306 | unloads.forEach((u) => u()); 307 | unloads.clear(); 308 | } 309 | }; 310 | }; 311 | 312 | export default loadTwitterFeed; 313 | -------------------------------------------------------------------------------- /lambdas/schedule-twitter.ts: -------------------------------------------------------------------------------- 1 | import subMinutes from "date-fns/subMinutes"; 2 | import startOfMinute from "date-fns/startOfMinute"; 3 | import addSeconds from "date-fns/addSeconds"; 4 | import querystring from "querystring"; 5 | import axios from "axios"; 6 | import FormData from "form-data"; 7 | import { dynamo, ses, twitterOAuth, s3 } from "./common/common"; 8 | import meterRoamJSUser from "roamjs-components/backend/meterRoamJSUser"; 9 | 10 | const ATTACHMENT_REGEX = /!\[[^\]]*\]\(([^\s)]*)\)/g; 11 | const UPLOAD_URL = "https://upload.twitter.com/1.1/media/upload.json"; 12 | const TWITTER_MAX_SIZE = 5000000; 13 | 14 | const toCategory = (mime: string) => { 15 | if (mime.startsWith("video")) { 16 | return "tweet_video"; 17 | } else if (mime.endsWith("gif")) { 18 | return "tweet_gif"; 19 | } else { 20 | return "tweet_image"; 21 | } 22 | }; 23 | const uploadAttachments = async ({ 24 | attachmentUrls, 25 | key, 26 | secret, 27 | }: { 28 | attachmentUrls: string[]; 29 | key: string; 30 | secret: string; 31 | }): Promise<{ media_ids: string[]; attachmentsError: string }> => { 32 | if (!attachmentUrls.length) { 33 | return Promise.resolve({ media_ids: [], attachmentsError: "" }); 34 | } 35 | const getPostOpts = (data: FormData) => ({ 36 | headers: { 37 | ...twitterOAuth.toHeader( 38 | twitterOAuth.authorize( 39 | { 40 | url: UPLOAD_URL, 41 | method: "POST", 42 | }, 43 | { key, secret } 44 | ) 45 | ), 46 | ...data.getHeaders(), 47 | }, 48 | }); 49 | 50 | const media_ids = [] as string[]; 51 | for (const attachmentUrl of attachmentUrls) { 52 | const attachment = await axios 53 | .get(attachmentUrl, { responseType: "arraybuffer" }) 54 | .then((r) => ({ 55 | data: r.data as ArrayBuffer, 56 | type: r.headers["content-type"], 57 | size: r.headers["content-length"], 58 | })); 59 | const media_category = toCategory(attachment.type); 60 | 61 | const initData = new FormData(); 62 | initData.append("command", "INIT"); 63 | initData.append("total_bytes", attachment.size); 64 | initData.append("media_type", attachment.type); 65 | initData.append("media_category", media_category); 66 | const { media_id, error } = await axios 67 | .post(UPLOAD_URL, initData, getPostOpts(initData)) 68 | .then((r) => ({ 69 | media_id: r.data.media_id_string, 70 | error: "", 71 | })) 72 | .catch((e) => ({ 73 | error: e.response.data.error, 74 | media_id: "", 75 | command: "INIT", 76 | mediaType: attachment.type, 77 | })); 78 | if (error) { 79 | return Promise.reject({ roamjsError: error }); 80 | } 81 | 82 | const data = Buffer.from(attachment.data).toString("base64"); 83 | for (let i = 0; i < data.length; i += TWITTER_MAX_SIZE) { 84 | const appendData = new FormData(); 85 | appendData.append("command", "APPEND"); 86 | appendData.append("media_id", media_id); 87 | appendData.append("media_data", data.slice(i, i + TWITTER_MAX_SIZE)); 88 | appendData.append("segment_index", i / TWITTER_MAX_SIZE); 89 | const { success, ...rest } = await axios 90 | .post(UPLOAD_URL, appendData, getPostOpts(appendData)) 91 | .then(() => ({ success: true })) 92 | .catch((error) => ({ 93 | success: false, 94 | error, 95 | command: `APPEND${i}`, 96 | mediaType: attachment.type, 97 | })); 98 | if (!success) { 99 | return Promise.reject(rest); 100 | } 101 | } 102 | const finalizeData = new FormData(); 103 | finalizeData.append("command", "FINALIZE"); 104 | finalizeData.append("media_id", media_id); 105 | const { success, ...rest } = await axios 106 | .post(UPLOAD_URL, finalizeData, getPostOpts(finalizeData)) 107 | .then(() => ({ success: true })) 108 | .catch((error) => ({ 109 | success: false, 110 | error, 111 | response: error.response?.data, 112 | command: `FINALIZE`, 113 | mediaType: attachment.type, 114 | })); 115 | if (!success) { 116 | return Promise.reject(rest); 117 | } 118 | 119 | if (media_category !== "tweet_image") { 120 | const url = `https://upload.twitter.com/1.1/media/upload.json?${querystring.stringify( 121 | { command: "STATUS", media_id } 122 | )}`; 123 | await new Promise((resolve, reject) => { 124 | const getStatus = () => { 125 | return axios 126 | .get(url, { 127 | headers: twitterOAuth.toHeader( 128 | twitterOAuth.authorize( 129 | { 130 | url, 131 | method: "GET", 132 | }, 133 | { key, secret } 134 | ) 135 | ), 136 | }) 137 | .then((r) => r.data.processing_info) 138 | .then(({ state, check_after_secs, error }) => { 139 | if (state === "succeeded") { 140 | resolve(); 141 | } else if (state === "failed") { 142 | reject(error.message); 143 | } else { 144 | setTimeout(getStatus, check_after_secs * 1000); 145 | } 146 | }); 147 | }; 148 | return getStatus(); 149 | }); 150 | } 151 | 152 | media_ids.push(media_id); 153 | } 154 | return { media_ids, attachmentsError: "" }; 155 | }; 156 | 157 | const channelHandler = async ({ 158 | oauth, 159 | uuid, 160 | }: { 161 | oauth: string; 162 | uuid: string; 163 | }): Promise => { 164 | const { blocks } = await s3 165 | .getObject({ 166 | Bucket: "roamjs-data", 167 | Key: `twitter/scheduled/${uuid}.json`, 168 | }) 169 | .promise() 170 | .then( 171 | (r) => JSON.parse(r.Body.toString()) as { blocks: { text: string }[] } 172 | ); 173 | const { oauth_token: key, oauth_token_secret: secret } = JSON.parse(oauth); 174 | 175 | let in_reply_to_status_id = ""; 176 | let failureIndex = -1; 177 | return blocks 178 | .map(({ text }, index) => async () => { 179 | if (failureIndex >= 0) { 180 | return { 181 | success: false, 182 | message: `Skipped sending tweet due to failing to send tweet ${failureIndex}`, 183 | }; 184 | } 185 | const attachmentUrls: string[] = []; 186 | const content = text.replace(ATTACHMENT_REGEX, (_, url) => { 187 | attachmentUrls.push( 188 | url.replace("www.dropbox.com", "dl.dropboxusercontent.com") 189 | ); 190 | return ""; 191 | }); 192 | const { media_ids, attachmentsError } = await uploadAttachments({ 193 | attachmentUrls, 194 | key, 195 | secret, 196 | }).catch(async (e) => { 197 | await ses 198 | .sendEmail({ 199 | Destination: { 200 | ToAddresses: ["support@roamjs.com"], 201 | }, 202 | Message: { 203 | Body: { 204 | Text: { 205 | Charset: "UTF-8", 206 | Data: `Scheduled Tweet while trying to upload attachments.\n\n${JSON.stringify( 207 | { 208 | message: e.response?.data || e.message || e, 209 | attachmentUrls, 210 | }, 211 | null, 212 | 4 213 | )}`, 214 | }, 215 | }, 216 | Subject: { 217 | Charset: "UTF-8", 218 | Data: `Social - Scheduled Tweet Failed`, 219 | }, 220 | }, 221 | Source: "support@roamjs.com", 222 | }) 223 | .promise(); 224 | const attachmentsError = 225 | e.roamjsError || "Email support@roamjs.com for help!"; 226 | return { media_ids: [] as string[], attachmentsError }; 227 | }); 228 | if (media_ids.length < attachmentUrls.length) { 229 | failureIndex = index; 230 | return { 231 | success: false, 232 | message: `Some attachments failed to upload. ${attachmentsError}`, 233 | }; 234 | } 235 | const data = { 236 | status: content, 237 | ...(media_ids.length ? { media_ids } : {}), 238 | ...(in_reply_to_status_id 239 | ? { in_reply_to_status_id, auto_populate_reply_metadata: true } 240 | : {}), 241 | }; 242 | 243 | const url = `https://api.twitter.com/1.1/statuses/update.json?${querystring 244 | .stringify(data) 245 | .replace(/!/g, "%21") 246 | .replace(/'/g, "%27") 247 | .replace(/\(/g, "%28") 248 | .replace(/\)/g, "%29") 249 | .replace(/\*/g, "%2A")}`; 250 | const oauthHeaders = twitterOAuth.toHeader( 251 | twitterOAuth.authorize( 252 | { 253 | url, 254 | method: "POST", 255 | }, 256 | { key, secret } 257 | ) 258 | ); 259 | return axios 260 | .post( 261 | url, 262 | {}, 263 | { 264 | headers: oauthHeaders, 265 | } 266 | ) 267 | .then((r) => { 268 | const { 269 | id_str, 270 | user: { screen_name }, 271 | } = r.data; 272 | in_reply_to_status_id = id_str; 273 | return { 274 | success: true, 275 | message: `https://twitter.com/${screen_name}/status/${id_str}`, 276 | }; 277 | }) 278 | .catch((e) => { 279 | failureIndex = index; 280 | return { 281 | success: false, 282 | message: e.response?.data?.errors 283 | ? (e.response?.data?.errors as { code: number }[]) 284 | .map(({ code }) => { 285 | switch (code) { 286 | case 220: 287 | return "Invalid credentials. Try logging in through the roam/js/twitter page"; 288 | case 186: 289 | return "Tweet is too long. Make it shorter!"; 290 | case 170: 291 | return "Tweet failed to send because it was empty."; 292 | case 187: 293 | return "Tweet failed to send because Twitter detected it was a duplicate."; 294 | default: 295 | return `Unknown error code (${code}). Email support@roamjs.com for help!`; 296 | } 297 | }) 298 | .join("\n") 299 | : (e.message as string), 300 | }; 301 | }); 302 | }) 303 | .reduce((prev, cur) => { 304 | return prev.then((outputs) => 305 | cur().then((output) => { 306 | outputs.push(output); 307 | return outputs; 308 | }) 309 | ); 310 | }, Promise.resolve([] as { message: string; success: boolean }[])) 311 | .then((tweets) => 312 | failureIndex >= 0 313 | ? Promise.reject(tweets[failureIndex].message) 314 | : Promise.resolve(tweets[0].message) 315 | ); 316 | }; 317 | 318 | export const handler = async () => { 319 | const now = startOfMinute(new Date()); 320 | const toMinute = addSeconds(now, 30); 321 | const lastMinute = subMinutes(toMinute, 1); 322 | 323 | return dynamo 324 | .query({ 325 | TableName: "RoamJSSocial", 326 | IndexName: "primary-index", 327 | KeyConditionExpression: "#c = :c AND #d BETWEEN :l AND :h", 328 | ExpressionAttributeValues: { 329 | ":l": { 330 | S: lastMinute.toJSON(), 331 | }, 332 | ":h": { 333 | S: toMinute.toJSON(), 334 | }, 335 | ":c": { 336 | S: process.env.NODE_ENV === "development" ? "development" : "twitter", 337 | }, 338 | }, 339 | ExpressionAttributeNames: { 340 | "#d": "date", 341 | "#c": "channel", 342 | }, 343 | }) 344 | .promise() 345 | .then((results) => 346 | Promise.all( 347 | results.Items.map((i) => 348 | channelHandler({ 349 | oauth: i.oauth.S, 350 | uuid: i.uuid.S, 351 | }) 352 | .then((message) => ({ 353 | uuid: i.uuid.S, 354 | success: true, 355 | message, 356 | email: i.userId.S, 357 | })) 358 | .catch((message) => ({ 359 | uuid: i.uuid.S, 360 | success: false, 361 | email: i.userId.S, 362 | message, 363 | })) 364 | ) 365 | ) 366 | ) 367 | .then((items) => 368 | items.forEach(({ uuid, success, message, email }) => 369 | dynamo 370 | .updateItem({ 371 | TableName: "RoamJSSocial", 372 | Key: { uuid: { S: uuid } }, 373 | UpdateExpression: "SET #s = :s, #m = :m, #u = :u", 374 | ExpressionAttributeNames: { 375 | "#s": "status", 376 | "#m": "message", 377 | }, 378 | ExpressionAttributeValues: { 379 | ":s": { S: success ? "SUCCESS" : "FAILED" }, 380 | ":m": { S: message }, 381 | }, 382 | }) 383 | .promise() 384 | ) 385 | ); 386 | }; 387 | -------------------------------------------------------------------------------- /src/ScheduledDashboard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | Button, 4 | Dialog, 5 | Intent, 6 | Popover, 7 | Spinner, 8 | } from "@blueprintjs/core"; 9 | import format from "date-fns/format"; 10 | import React, { useCallback, useEffect, useMemo, useState } from "react"; 11 | import getParentUidByBlockUid from "roamjs-components/queries/getParentUidByBlockUid"; 12 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 13 | import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; 14 | import resolveRefs from "roamjs-components/dom/resolveRefs"; 15 | import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; 16 | import type { OnloadArgs, TreeNode } from "roamjs-components/types"; 17 | import apiDelete from "roamjs-components/util/apiDelete"; 18 | import apiGet from "roamjs-components/util/apiGet"; 19 | import apiPut from "roamjs-components/util/apiPut"; 20 | import startOfMinute from "date-fns/startOfMinute"; 21 | import addMinutes from "date-fns/addMinutes"; 22 | import endOfYear from "date-fns/endOfYear"; 23 | import addYears from "date-fns/addYears"; 24 | import { DatePicker } from "@blueprintjs/datetime"; 25 | import renderOverlay, { 26 | RoamOverlayProps, 27 | } from "roamjs-components/util/renderOverlay"; 28 | 29 | type AttemptedTweet = { 30 | status: "FAILED" | "SUCCESS"; 31 | message: string; 32 | }; 33 | 34 | type PendingTweet = { 35 | status: "PENDING"; 36 | }; 37 | 38 | export type ScheduledTweet = { 39 | uuid: string; 40 | blockUid: string; 41 | createdDate: string; 42 | scheduledDate: string; 43 | } & (AttemptedTweet | PendingTweet); 44 | 45 | const DeleteScheduledContent = ({ onConfirm }: { onConfirm: () => void }) => { 46 | const [isOpen, setIsOpen] = useState(false); 47 | const open = useCallback(() => setIsOpen(true), [setIsOpen]); 48 | const close = useCallback(() => setIsOpen(false), [setIsOpen]); 49 | return ( 50 | <> 51 | 176 | 177 | 178 | 179 | ); 180 | }; 181 | 182 | const ScheduledDashboard = ({ isOpen, onClose }: RoamOverlayProps) => { 183 | const [loading, setLoading] = useState(true); 184 | const [error, setError] = useState(""); 185 | const [valid, setValid] = useState(false); 186 | const [scheduledTweets, setScheduledTweets] = useState([]); 187 | const refresh = useCallback(() => { 188 | setLoading(true); 189 | apiGet<{ scheduledTweets: ScheduledTweet[] }>("twitter-schedule") 190 | .then((r) => { 191 | setValid(true); 192 | setScheduledTweets( 193 | r.scheduledTweets.sort( 194 | ({ createdDate: a }, { createdDate: b }) => 195 | new Date(b).valueOf() - new Date(a).valueOf() 196 | ) 197 | ); 198 | }) 199 | .catch((e) => setError(e.response?.data && e.message)) 200 | .finally(() => setLoading(false)); 201 | }, [setLoading, setValid]); 202 | useEffect(() => { 203 | if (loading) { 204 | refresh(); 205 | } 206 | }, [loading, refresh]); 207 | return ( 208 | 215 | {loading ? ( 216 | 217 | ) : valid ? ( 218 | <> 219 | {scheduledTweets.length ? ( 220 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | {scheduledTweets.map( 235 | ({ 236 | uuid, 237 | blockUid, 238 | scheduledDate, 239 | createdDate, 240 | ...statusProps 241 | }) => { 242 | return ( 243 | 244 | 273 | 281 | 284 | 290 | 333 | 334 | ); 335 | } 336 | )} 337 | 338 |
BlockCreated DateScheduled DateStatus
245 | {statusProps.status === "PENDING" && ( 246 | 251 | setScheduledTweets( 252 | scheduledTweets.map((t) => 253 | t.uuid === uuid 254 | ? { ...t, scheduledDate: date } 255 | : t 256 | ) 257 | ) 258 | } 259 | /> 260 | )} 261 | 263 | apiDelete( 264 | `twitter-schedule?uuid=${uuid}` 265 | ).then(() => 266 | setScheduledTweets( 267 | scheduledTweets.filter((t) => t.uuid !== uuid) 268 | ) 269 | ) 270 | } 271 | /> 272 | 274 | openBlockInSidebar(blockUid)} 277 | > 278 | (({blockUid})) 279 | 280 | 282 | {format(new Date(createdDate), "yyyy/MM/dd hh:mm a")} 283 | 285 | {format( 286 | new Date(scheduledDate), 287 | "yyyy/MM/dd hh:mm a" 288 | )} 289 | 291 | {statusProps.status === "SUCCESS" && ( 292 | 298 | SUCCESS 299 | 300 | )} 301 | {statusProps.status === "PENDING" && ( 302 | 303 | PENDING 304 | 305 | )} 306 | {statusProps.status === "FAILED" && ( 307 | 315 |
316 | {statusProps.message} 317 |
318 | 319 | } 320 | target={ 321 | 327 | FAILED 328 | 329 | } 330 | /> 331 | )} 332 |
339 | ) : ( 340 | <> 341 |
342 | You have not scheduled any Tweets from Roam. Create a block with{" "} 343 | {`{{[[tweet]]}}`} to get started! 344 |
345 | 346 | )} 347 |
366 | ); 367 | }; 368 | 369 | const loadTwitterScheduling = (args: OnloadArgs) => { 370 | const unloads = new Set<() => void>(); 371 | return (enabled: boolean) => { 372 | if (enabled) { 373 | const mark = args.extensionAPI.settings.get("mark-blocks"); 374 | (mark 375 | ? apiGet<{ scheduledTweets: ScheduledTweet[] }>("twitter-schedule") 376 | .then((r) => { 377 | const scheduledTweets = r.scheduledTweets; 378 | const pendingBlockUids = new Set( 379 | scheduledTweets 380 | .filter((s) => s.status === "PENDING") 381 | .map((s) => s.blockUid) 382 | ); 383 | const successBlockUids = new Set( 384 | scheduledTweets 385 | .filter((s) => s.status === "SUCCESS") 386 | .map((s) => s.blockUid) 387 | ); 388 | const failedBlockUids = new Set( 389 | scheduledTweets 390 | .filter((s) => s.status === "FAILED") 391 | .map((s) => s.blockUid) 392 | ); 393 | return { pendingBlockUids, successBlockUids, failedBlockUids }; 394 | }) 395 | .catch((e) => console.error(e.response?.data && e.message)) 396 | : Promise.resolve(undefined) 397 | ).then((markInfo) => { 398 | document.body.dispatchEvent( 399 | new CustomEvent(`roamjs:twitter:mark`, { detail: markInfo }) 400 | ); 401 | }); 402 | window.roamAlphaAPI.ui.commandPalette.addCommand({ 403 | label: "Open Scheduled Tweets", 404 | callback: () => { 405 | renderOverlay({ 406 | id: "scheduled-tweets", 407 | Overlay: ScheduledDashboard, 408 | }); 409 | }, 410 | }); 411 | unloads.add(() => 412 | window.roamAlphaAPI.ui.commandPalette.removeCommand({ 413 | label: "Open Scheduled Tweets", 414 | }) 415 | ); 416 | } else { 417 | unloads.clear(); 418 | unloads.forEach((u) => u()); 419 | } 420 | }; 421 | }; 422 | 423 | export default loadTwitterScheduling; 424 | -------------------------------------------------------------------------------- /src/TweetOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | Button, 4 | Icon, 5 | IconName, 6 | Popover, 7 | Portal, 8 | Spinner, 9 | Text, 10 | Tooltip, 11 | } from "@blueprintjs/core"; 12 | import { DatePicker } from "@blueprintjs/datetime"; 13 | import React, { 14 | useCallback, 15 | useEffect, 16 | useMemo, 17 | useRef, 18 | useState, 19 | } from "react"; 20 | import ReactDOM from "react-dom"; 21 | import Twitter from "./TwitterLogo.svg"; 22 | import getEditTimeByBlockUid from "roamjs-components/queries/getEditTimeByBlockUid"; 23 | import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; 24 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 25 | import getUids from "roamjs-components/dom/getUids"; 26 | import updateBlock from "roamjs-components/writes/updateBlock"; 27 | import getRoamUrlByPage from "roamjs-components/dom/getRoamUrlByPage"; 28 | import resolveRefs from "roamjs-components/dom/resolveRefs"; 29 | import { BLOCK_REF_REGEX } from "roamjs-components/dom/constants"; 30 | import extractRef from "roamjs-components/util/extractRef"; 31 | import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; 32 | import getOauth from "roamjs-components/util/getOauth"; 33 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 34 | import { useOauthAccounts } from "roamjs-components/components/OauthSelect"; 35 | import apiPost from "roamjs-components/util/apiPost"; 36 | import axios from "axios"; 37 | import twitter from "twitter-text"; 38 | import addYears from "date-fns/addYears"; 39 | import endOfYear from "date-fns/endOfYear"; 40 | import format from "date-fns/format"; 41 | import addMinutes from "date-fns/addMinutes"; 42 | import startOfMinute from "date-fns/startOfMinute"; 43 | import apiGet from "roamjs-components/util/apiGet"; 44 | import getFirstChildUidByBlockUid from "roamjs-components/queries/getFirstChildUidByBlockUid"; 45 | import toFlexRegex from "roamjs-components/util/toFlexRegex"; 46 | import differenceInMilliseconds from "date-fns/differenceInMilliseconds"; 47 | import { OnloadArgs } from "roamjs-components/types"; 48 | 49 | const ATTACHMENT_REGEX = /!\[[^\]]*\]\(([^\s)]*)\)/g; 50 | const UPLOAD_URL = `${process.env.API_URL}/twitter-upload`; 51 | const TWITTER_MAX_SIZE = 5000000; 52 | 53 | const toCategory = (mime: string) => { 54 | if (mime.startsWith("video")) { 55 | return "tweet_video"; 56 | } else if (mime.endsWith("gif")) { 57 | return "tweet_gif"; 58 | } else { 59 | return "tweet_image"; 60 | } 61 | }; 62 | 63 | const Error: React.FunctionComponent<{ error: string }> = ({ error }) => 64 | error ? ( 65 |
66 | {error} 67 |
68 | ) : ( 69 | <> 70 | ); 71 | 72 | const RoamRef = ({ uid }: { uid: string }) => { 73 | return ( 74 | { 78 | if (e.shiftKey) { 79 | openBlockInSidebar(uid); 80 | e.preventDefault(); 81 | } else { 82 | window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); 83 | } 84 | }} 85 | > 86 | (({uid})) 87 | 88 | ); 89 | }; 90 | 91 | const uploadAttachments = async ({ 92 | attachmentUrls, 93 | key, 94 | secret, 95 | }: { 96 | attachmentUrls: string[]; 97 | key: string; 98 | secret: string; 99 | }): Promise => { 100 | if (!attachmentUrls.length) { 101 | return Promise.resolve([]); 102 | } 103 | const mediaIds = []; 104 | for (const attachmentUrl of attachmentUrls) { 105 | const attachment = await axios 106 | .get(attachmentUrl, { responseType: "blob" }) 107 | .then((r) => r.data as Blob); 108 | const media_category = toCategory(attachment.type); 109 | const { media_id, error } = await axios 110 | .post(UPLOAD_URL, { 111 | key, 112 | secret, 113 | params: { 114 | command: "INIT", 115 | total_bytes: attachment.size, 116 | media_type: attachment.type, 117 | media_category, 118 | }, 119 | }) 120 | .then((r) => ({ media_id: r.data.media_id_string, error: "" })) 121 | .catch((e) => ({ error: e.response.data.error, media_id: "" })); 122 | if (error) { 123 | return Promise.reject({ roamjsError: error }); 124 | } 125 | const reader = new FileReader(); 126 | const data = await new Promise((resolve) => { 127 | reader.onloadend = () => resolve((reader.result as string).split(",")[1]); 128 | reader.readAsDataURL(attachment); 129 | }); 130 | for (let i = 0; i < data.length; i += TWITTER_MAX_SIZE) { 131 | await axios.post(UPLOAD_URL, { 132 | key, 133 | secret, 134 | params: { 135 | command: "APPEND", 136 | media_id, 137 | media_data: data.slice(i, i + TWITTER_MAX_SIZE), 138 | segment_index: i / TWITTER_MAX_SIZE, 139 | }, 140 | }); 141 | } 142 | await axios.post(UPLOAD_URL, { 143 | key, 144 | secret, 145 | params: { command: "FINALIZE", media_id }, 146 | }); 147 | 148 | if (media_category !== "tweet_image") { 149 | await new Promise((resolve, reject) => { 150 | const getStatus = () => 151 | axios 152 | .post(UPLOAD_URL, { 153 | key, 154 | secret, 155 | params: { command: "STATUS", media_id }, 156 | }) 157 | .then((r) => r.data.processing_info) 158 | .then(({ state, check_after_secs, error }) => { 159 | if (state === "succeeded") { 160 | resolve(); 161 | } else if (state === "failed") { 162 | reject(error.message); 163 | } else { 164 | setTimeout(getStatus, check_after_secs * 1000); 165 | } 166 | }); 167 | return getStatus(); 168 | }); 169 | } 170 | 171 | mediaIds.push(media_id); 172 | } 173 | return mediaIds; 174 | }; 175 | 176 | const TwitterContent: React.FunctionComponent< 177 | Props & { 178 | close: () => void; 179 | setDialogMessage: (m: string) => void; 180 | markInfo: MarkInfo; 181 | } 182 | > = ({ 183 | close, 184 | blockUid, 185 | tweetId, 186 | setDialogMessage, 187 | markInfo, 188 | extensionAPI, 189 | }) => { 190 | const message = useMemo( 191 | () => 192 | getBasicTreeByParentUid(blockUid).map((t) => ({ 193 | ...t, 194 | text: resolveRefs(t.text), 195 | })), 196 | [blockUid] 197 | ); 198 | const [error, setError] = useState(""); 199 | const [tweetsSent, setTweetsSent] = useState(0); 200 | const { accountLabel, accountDropdown } = useOauthAccounts("twitter"); 201 | const onClick = useCallback(async () => { 202 | setError(""); 203 | const oauth = getOauth("twitter"); 204 | if (oauth === "{}") { 205 | setError( 206 | "Need to log in with Twitter to send Tweets! Head to roam/js/twitter page to log in." 207 | ); 208 | return; 209 | } 210 | const { oauth_token: key, oauth_token_secret: secret } = JSON.parse(oauth); 211 | const sentBlockUid = ((extensionAPI.settings.get("sent") as string) || "") 212 | .replace("((", "") 213 | .replace("))", ""); 214 | const sentLabel = 215 | (extensionAPI.settings.get("label") as string) || "Sent at {now}"; 216 | const appendText = 217 | (extensionAPI.settings.get("append-text") as string) || ""; 218 | const sentBlockIsValid = 219 | sentBlockUid && !!getEditTimeByBlockUid(sentBlockUid); 220 | const sourceUid = window.roamAlphaAPI.util.generateUID(); 221 | if (sentBlockIsValid) { 222 | window.roamAlphaAPI.createBlock({ 223 | location: { "parent-uid": sentBlockUid, order: 0 }, 224 | block: { 225 | string: sentLabel.replace(/{now}/g, new Date().toLocaleString()), 226 | uid: sourceUid, 227 | }, 228 | }); 229 | } 230 | let in_reply_to_status_id = tweetId; 231 | let success = true; 232 | const links: string[] = []; 233 | const regexTags = /(#?\[\[([^\]]*)\]\])/g; 234 | for (let index = 0; index < message.length; index++) { 235 | setTweetsSent(index + 1); 236 | const { text, uid } = message[index]; 237 | const attachmentUrls: string[] = []; 238 | const content = text.replace(ATTACHMENT_REGEX, (_, url) => { 239 | attachmentUrls.push( 240 | url.replace("www.dropbox.com", "dl.dropboxusercontent.com") 241 | ); 242 | return ""; 243 | }).replace(!!extensionAPI.settings.get("parse-tags") ? regexTags : /.^/, (match, _, tag) => 244 | tag) 245 | const media_ids = await uploadAttachments({ 246 | attachmentUrls, 247 | key, 248 | secret, 249 | }).catch((e) => { 250 | console.error(e.response?.data || e.message || e); 251 | setTweetsSent(0); 252 | if (e.roamjsError) { 253 | setError(e.roamjsError); 254 | } else { 255 | setError( 256 | "Some attachments failed to upload. Email support@roamjs.com for help!" 257 | ); 258 | } 259 | return []; 260 | }); 261 | if (media_ids.length < attachmentUrls.length) { 262 | return ""; 263 | } 264 | success = await axios 265 | .post(`${process.env.API_URL}/twitter-tweet`, { 266 | key, 267 | secret, 268 | content, 269 | in_reply_to_status_id, 270 | auto_populate_reply_metadata: !!in_reply_to_status_id, 271 | media_ids, 272 | }) 273 | .then((r) => { 274 | const { 275 | id_str, 276 | user: { screen_name }, 277 | } = r.data; 278 | in_reply_to_status_id = id_str; 279 | const link = `https://twitter.com/${screen_name}/status/${id_str}`; 280 | links.push(link); 281 | if (appendText) { 282 | window.roamAlphaAPI.updateBlock({ 283 | block: { 284 | uid, 285 | string: `${text} ${appendText.replace("{link}", link)}`, 286 | }, 287 | }); 288 | } 289 | if (sentBlockIsValid) { 290 | window.roamAlphaAPI.moveBlock({ 291 | location: { "parent-uid": sourceUid, order: index }, 292 | block: { uid }, 293 | }); 294 | } 295 | return true; 296 | }) 297 | .catch((e) => { 298 | if (sentBlockIsValid && index === 0) { 299 | window.roamAlphaAPI.deleteBlock({ block: { uid: sourceUid } }); 300 | } 301 | setError( 302 | e.response?.data?.errors 303 | ? e.response?.data?.errors 304 | .map(({ code }: { code: number }) => { 305 | switch (code) { 306 | case 220: 307 | return "Invalid credentials. Try logging in through the roam/js/twitter page"; 308 | case 186: 309 | return "Tweet is too long. Make it shorter!"; 310 | case 170: 311 | return "Tweet failed to send because it was empty."; 312 | case 187: 313 | return "Tweet failed to send because Twitter detected it was a duplicate."; 314 | default: 315 | return `Unknown error code (${code}). Email support@roamjs.com for help!`; 316 | } 317 | }) 318 | .join("\n") 319 | : e.message 320 | ); 321 | setTweetsSent(0); 322 | return false; 323 | }); 324 | if (!success) { 325 | break; 326 | } 327 | } 328 | if (success) { 329 | const appendParent = 330 | (extensionAPI.settings.get("append-parent") as string) || ""; 331 | if (appendParent) { 332 | const text = getTextByBlockUid(blockUid); 333 | updateBlock({ 334 | uid: blockUid, 335 | text: `${text}${appendParent 336 | .replace(/{link}/g, links[0]) 337 | .replace(/{now}/g, new Date().toLocaleString())}`, 338 | }); 339 | } 340 | close(); 341 | } 342 | }, [setTweetsSent, close, setError, tweetId, accountLabel]); 343 | 344 | const initialDate = useMemo( 345 | () => addMinutes(startOfMinute(new Date()), 1), 346 | [] 347 | ); 348 | const schedulingEnabled = useMemo( 349 | () => !!extensionAPI.settings.get("scheduling-enabled"), 350 | [] 351 | ); 352 | const [showSchedule, setShowSchedule] = useState(false); 353 | const [loading, setLoading] = useState(false); 354 | const [scheduleDate, setScheduleDate] = useState(new Date()); 355 | const openSchedule = useCallback(() => setShowSchedule(true), [ 356 | setShowSchedule, 357 | ]); 358 | const closeSchedule = useCallback(() => setShowSchedule(false), [ 359 | setShowSchedule, 360 | ]); 361 | const onScheduleClick = useCallback(() => { 362 | const oauth = getOauth("twitter", accountLabel); 363 | if (oauth === "{}") { 364 | setError( 365 | "Need to log in with Twitter to schedule Tweets! Head to roam/js/twitter page to log in." 366 | ); 367 | return; 368 | } 369 | setLoading(true); 370 | apiPost<{ id: string; status: string }>("twitter-schedule", { 371 | scheduleDate: scheduleDate.toJSON(), 372 | payload: JSON.stringify({ blocks: message, tweetId }), 373 | oauth, 374 | }) 375 | .then((r) => { 376 | setLoading(false); 377 | setDialogMessage( 378 | `Tweet Successfully Scheduled to post at ${format( 379 | scheduleDate, 380 | "yyyy/MM/dd hh:mm:ss a" 381 | )}!` 382 | ); 383 | if (markInfo) { 384 | const indexUid = message[0].uid; 385 | markInfo.pendingBlockUids.add(indexUid); 386 | setTimeout( 387 | () => 388 | apiGet(`twitter-schedule?id=${r.id}`) 389 | .then((r) => { 390 | if (r.status === "SUCCESS") 391 | markInfo.successBlockUids.add(indexUid); 392 | else markInfo.failedBlockUids.add(indexUid); 393 | }) 394 | .catch((e) => console.error(e)), 395 | differenceInMilliseconds(scheduleDate, new Date()) + 60000 396 | ); 397 | } 398 | }) 399 | .catch((e) => { 400 | setError(e.response?.data); 401 | setLoading(false); 402 | return false; 403 | }) 404 | .then((success: boolean) => success && close()); 405 | }, [ 406 | setError, 407 | close, 408 | setLoading, 409 | scheduleDate, 410 | setDialogMessage, 411 | message, 412 | tweetId, 413 | accountLabel, 414 | apiPost, 415 | ]); 416 | return ( 417 |
418 | {showSchedule ? ( 419 | <> 420 |
441 | 442 | 443 | 444 | ) : ( 445 | <> 446 | {accountDropdown} 447 |