├── .gitignore ├── static ├── 1px.png ├── verified.png ├── blue_verified.png └── piss_verified.png ├── config.ts ├── bridge_config.template.json ├── utils ├── userCache.ts ├── apiUtil.ts └── oauth.ts ├── README.md ├── apis └── authflow.ts ├── conversion.ts └── main.ts /.gitignore: -------------------------------------------------------------------------------- 1 | bridge_config.json 2 | -------------------------------------------------------------------------------- /static/1px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Treeki/BirdBridge/HEAD/static/1px.png -------------------------------------------------------------------------------- /static/verified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Treeki/BirdBridge/HEAD/static/verified.png -------------------------------------------------------------------------------- /static/blue_verified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Treeki/BirdBridge/HEAD/static/blue_verified.png -------------------------------------------------------------------------------- /static/piss_verified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Treeki/BirdBridge/HEAD/static/piss_verified.png -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | headers: Record, 3 | bridge_password: string, 4 | bridge_secret: string, 5 | consumer_key: string, 6 | consumer_secret: string, 7 | root: string, 8 | domain: string 9 | } 10 | 11 | export const CONFIG: Config = JSON.parse(Deno.readTextFileSync('bridge_config.json')); 12 | -------------------------------------------------------------------------------- /bridge_config.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "X-Twitter-Active-User": "yes", 4 | "X-Twitter-API-Version": "5", 5 | "X-Twitter-Client": "Twitter-iPhone", 6 | "X-Twitter-Client-DeviceID": "00000000-0000-0000-0000-000000000000", 7 | "X-Twitter-Client-Language": "en", 8 | "X-Twitter-Client-Version": "9.41.1", 9 | "User-Agent": "Twitter-iPhone/9.41.1 iOS/16.2 (Apple;iPhone10,5;;;;;1;2017)" 10 | }, 11 | "bridge_password": "[Password that will be required when signing into the bridge]", 12 | "bridge_secret": "[Replace me with random data]", 13 | "consumer_key": "[Replace me with a valid API key]", 14 | "consumer_secret": "[Replace me with a valid API key secret]", 15 | "root": "https://cool-subdomain.example.com", 16 | "domain": "cool-subdomain.example.com" 17 | } 18 | -------------------------------------------------------------------------------- /utils/userCache.ts: -------------------------------------------------------------------------------- 1 | import {OAuth} from "./oauth.ts"; 2 | import {buildParams} from "./apiUtil.ts"; 3 | 4 | enum Status { 5 | WAITING, 6 | DONE, 7 | FAILED 8 | } 9 | 10 | async function plainFetchUser(oauth: OAuth, id: string): Promise> { 11 | const params = buildParams(true); 12 | params.user_id = id; 13 | const twreq = await oauth.request('GET', 'https://api.twitter.com/1.1/users/show.json', params); 14 | return twreq.json(); 15 | } 16 | 17 | class UserCacheEntry { 18 | status: Status; 19 | promise: Promise>; 20 | expiry?: number; 21 | 22 | constructor(id: string, promise: Promise>) { 23 | this.status = Status.WAITING; 24 | this.promise = promise; 25 | promise.then(_ => { 26 | console.log('Fetched user ID ' + id); 27 | this.status = Status.DONE; 28 | this.expiry = (new Date().getTime()) + (60 * 1000); 29 | }).catch(reason => { 30 | console.error('Failed to fetch user', reason); 31 | this.status = Status.FAILED; 32 | }); 33 | } 34 | 35 | get invalid() { 36 | if (this.status === Status.FAILED) 37 | return true; 38 | if (this.expiry !== undefined && (new Date().getTime()) > this.expiry) 39 | return true; 40 | return false; 41 | } 42 | } 43 | 44 | export class UserCache { 45 | oauth: OAuth; 46 | map: Map; 47 | 48 | constructor(oauth: OAuth) { 49 | this.oauth = oauth; 50 | this.map = new Map(); 51 | } 52 | 53 | fetchUser(id: string): Promise> { 54 | let entry = this.map.get(id); 55 | if (entry !== undefined && !entry.invalid) { 56 | console.log('Reusing user ID ' + id); 57 | return entry.promise; 58 | } 59 | 60 | console.log('Fetching user ID ' + id); 61 | const promise = plainFetchUser(this.oauth, id); 62 | entry = new UserCacheEntry(id, promise); 63 | this.map.set(id, entry); 64 | return promise; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /utils/apiUtil.ts: -------------------------------------------------------------------------------- 1 | // @deno-types="npm:@types/express@4.17.15" 2 | import express from "npm:express@4.18.2"; 3 | import {CONFIG} from "../config.ts"; 4 | 5 | export const BLUE_VERIFIED_EMOJI = { 6 | shortcode: 'blue_verified', 7 | url: new URL('/static/blue_verified.png', CONFIG.root).toString(), 8 | static_url: new URL('/static/blue_verified.png', CONFIG.root).toString(), 9 | visible_in_picker: false, 10 | category: 'Icons' 11 | }; 12 | export const VERIFIED_EMOJI = { 13 | shortcode: 'verified', 14 | url: new URL('/static/verified.png', CONFIG.root).toString(), 15 | static_url: new URL('/static/verified.png', CONFIG.root).toString(), 16 | visible_in_picker: false, 17 | category: 'Icons' 18 | }; 19 | export const PISS_VERIFIED_EMOJI = { 20 | shortcode: 'piss_verified', 21 | url: new URL('/static/piss_verified.png', CONFIG.root).toString(), 22 | static_url: new URL('/static/piss_verified.png', CONFIG.root).toString(), 23 | visible_in_picker: false, 24 | category: 'Icons' 25 | }; 26 | 27 | export const IMAGE_1PX = new URL('/static/1px.png', CONFIG.root).toString(); 28 | 29 | export function buildParams(isTweet: boolean): Record { 30 | const params: Record = { 31 | include_cards: '1', 32 | cards_platform: 'iPhone-13', 33 | include_entities: '1', 34 | include_user_entities: '1', 35 | include_ext_trusted_friends_metadata: 'true', 36 | include_ext_verified_type: 'true', 37 | include_ext_is_blue_verified: 'true', 38 | include_ext_vibe: 'true', 39 | include_ext_alt_text: 'true', 40 | include_composer_source: 'true', 41 | include_quote_count: '1', 42 | include_reply_count: '1', 43 | tweet_mode: 'extended' 44 | }; 45 | 46 | return params; 47 | } 48 | 49 | export function injectPagingInfo(query: Record, params: Record) { 50 | if (query.limit !== undefined) 51 | params.count = query.limit; 52 | if (query.max_id !== undefined) 53 | params.max_id = query.max_id; 54 | if (query.since_id !== undefined) 55 | params.since_id = query.since_id; 56 | if (query.min_id !== undefined) 57 | params.since_id = query.min_id; 58 | } 59 | 60 | export function addPageLinksToResponse(url: URL, items: {id: string}[], response: express.Response) { 61 | if (items.length === 0) 62 | return; 63 | 64 | let lowestID = 99999999999999999999n; 65 | let highestID = 0n; 66 | for (const item of items) { 67 | const id = BigInt(item.id); 68 | if (id < lowestID) 69 | lowestID = id; 70 | if (id > highestID) 71 | highestID = id; 72 | } 73 | 74 | url.searchParams.delete('min_id'); 75 | url.searchParams.delete('max_id'); 76 | url.searchParams.delete('since_id'); 77 | 78 | // the previous page represents newer content (higher IDs) 79 | url.searchParams.set('min_id', highestID.toString()); 80 | const prevURL = url.toString(); 81 | 82 | // the next page represents older content (lower IDs) 83 | url.searchParams.delete('min_id'); 84 | url.searchParams.set('max_id', (lowestID - 1n).toString()); 85 | const nextURL = url.toString(); 86 | 87 | response.header('Link', `<${prevURL}>; rel="prev", <${nextURL}>; rel="next"`); 88 | } 89 | -------------------------------------------------------------------------------- /utils/oauth.ts: -------------------------------------------------------------------------------- 1 | import { crypto, toHashString } from "https://deno.land/std@0.173.0/crypto/mod.ts"; 2 | import {CONFIG} from "../config.ts"; 3 | 4 | function percentEncode(s: string): string { 5 | return encodeURIComponent(s).replace(/['()*!]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); 6 | } 7 | 8 | async function buildHmacKey(consumerSecret: string, accessTokenSecret: string): Promise { 9 | const string = percentEncode(consumerSecret) + '&' + percentEncode(accessTokenSecret); 10 | const buffer = new TextEncoder().encode(string).buffer; 11 | return crypto.subtle.importKey('raw', buffer, {name: 'HMAC', hash: 'SHA-1'}, false, ['sign']); 12 | } 13 | 14 | async function makeSignature(hmacKey: CryptoKey, oauthParams: Record, method: string, url: string|URL, params: URLSearchParams|null): Promise { 15 | const paramBits = []; 16 | 17 | for (const [key, value] of Object.entries(oauthParams)) { 18 | paramBits.push(`${percentEncode(key)}=${percentEncode(value)}`); 19 | } 20 | 21 | if (typeof url === 'string') { 22 | url = new URL(url); 23 | } 24 | url.searchParams.forEach((value, key) => { 25 | paramBits.push(`${percentEncode(key)}=${percentEncode(value)}`); 26 | }); 27 | 28 | if (params !== null) { 29 | for (const [key, value] of Object.entries(params)) { 30 | paramBits.push(`${percentEncode(key)}=${percentEncode(value)}`); 31 | } 32 | } 33 | 34 | const parameterString = percentEncode(paramBits.sort().join('&')); 35 | const urlBase = percentEncode(url.origin + url.pathname); 36 | const baseString = `${method.toUpperCase()}&${urlBase}&${parameterString}`; 37 | const baseArray = new TextEncoder().encode(baseString); 38 | const hash = await crypto.subtle.sign('HMAC', hmacKey, baseArray.buffer); 39 | return toHashString(hash, 'base64'); 40 | } 41 | 42 | export class OAuth { 43 | consumerKey: string; 44 | consumerSecret: string; 45 | accessToken: string; 46 | accessTokenSecret: string; 47 | myID: string; 48 | key?: CryptoKey; 49 | 50 | constructor(consumerKey: string, consumerSecret: string, accessToken: string, accessTokenSecret: string) { 51 | this.consumerKey = consumerKey; 52 | this.consumerSecret = consumerSecret; 53 | this.accessToken = accessToken; 54 | this.accessTokenSecret = accessTokenSecret; 55 | this.myID = accessToken.substring(0, accessToken.indexOf('-')); 56 | } 57 | 58 | async request(method: string, url: string|URL, queryParams?: Record, bodyParams?: Record | null): Promise { 59 | if (this.key === undefined) { 60 | this.key = await buildHmacKey(this.consumerSecret, this.accessTokenSecret); 61 | } 62 | 63 | const oauthParams: Record = { 64 | oauth_consumer_key: this.consumerKey, 65 | oauth_nonce: crypto.randomUUID().toUpperCase(), 66 | oauth_signature_method: 'HMAC-SHA1', 67 | oauth_timestamp: Math.floor(new Date().getTime() / 1000).toString(), 68 | oauth_token: this.accessToken, 69 | oauth_version: '1.0' 70 | }; 71 | 72 | url = new URL(url); 73 | if (queryParams !== undefined) { 74 | for (const [key, value] of Object.entries(queryParams)) { 75 | url.searchParams.append(key, value); 76 | } 77 | } 78 | 79 | let body = null; 80 | if (bodyParams !== undefined && bodyParams !== null) { 81 | body = new URLSearchParams(bodyParams); 82 | } 83 | 84 | oauthParams['oauth_signature'] = await makeSignature(this.key, oauthParams, method, url, body); 85 | 86 | const oauthBits = []; 87 | for (const [key, value] of Object.entries(oauthParams)) { 88 | oauthBits.push(`${percentEncode(key)}="${percentEncode(value)}"`); 89 | } 90 | 91 | const headers: Record = { ...CONFIG.headers }; 92 | headers['Authorization'] = 'OAuth ' + oauthBits.join(', '); 93 | 94 | return fetch(url, { body, headers, method }); 95 | } 96 | 97 | get(url: string|URL, queryParams?: Record, bodyParams?: Record | null): Promise { 98 | return this.request('GET', url, queryParams, bodyParams); 99 | } 100 | post(url: string|URL, queryParams?: Record, bodyParams?: Record | null): Promise { 101 | return this.request('POST', url, queryParams, bodyParams); 102 | } 103 | 104 | /*async postGraphQL(key: string, variables: Record, features?: Record): Promise { 105 | 106 | }*/ 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BirdBridge 2 | 3 | A tiny server written using Deno and Express which allows Mastodon clients to access Twitter. 4 | 5 | This app doesn't store any data, it just acts as a proxy that rewrites requests in cool ways. 6 | 7 | Tested with: [Pinafore](https://pinafore.social/), [Elk](https://elk.zone/), [Ivory for iOS](https://tapbots.com/ivory/0j) 8 | 9 | ⚠️ This software is provided for educational purposes. You shouldn't actually use this to post on Twitter - after all, there are [long-standing API rules](https://twitter.com/TwitterDev/status/1615405842735714304) against third-party clients. Please don't make Elon Musk upset. :( 10 | 11 | ## Requirements 12 | 13 | - [Deno](https://deno.land/) (developed and tested with version 1.29.4) 14 | - A server with a SSL certificate and a subdomain 15 | - API keys that have fallen off the back of a truck 16 | 17 | ## Acknowledgements 18 | 19 | - Nerd verification icon (used for people who have Twitter Blue) from [@shadowbIood@twitter.com](https://twitter.com/shadowbIood/status/1590462560515473409) 20 | 21 | ## Twitter Features 22 | 23 | | Feature | Status | Notes | 24 | |------------------------|--------|---------------------------------------------------------------------------------------------------------------------------------------------| 25 | | View Home Timeline | ✅ | | 26 | | View Mentions Timeline | ✅ | | 27 | | View Notifications | 🔶 | Push not supported. Notification type filters are currently ignored. Quote tweets, favs/RTs of mentions and favs/RTs of your RTs not shown. | 28 | | View Tweet | 🔶 | Quoted tweets in threads are incorrectly displayed as top-level members of the thread. | 29 | | View Profile | 🔶 | Profiles and tweets can be viewed, but following/followers lists don't work yet. "No RTs" view missing. Some profile metadata missing. | 30 | | View List Timeline | ✅* | May need pagination fixes. | 31 | | View Lists | ✅* | Mastodon doesn't let you view other people's lists, so this metadata isn't exposed | 32 | | Edit Lists | ❌ | May be impractical | 33 | | Create Tweets | 🔶 | Text-only tweets and replies supported. No reply controls or scheduling yet | 34 | | Delete Tweets | ✅ | | 35 | | Retweet | ✅ | | 36 | | Fav/Like Tweets | ✅ | | 37 | | View Media | ✅ | Images, videos, fake-GIFs all supported | 38 | | Quote Tweets | 🔶 | Quote tweets are rewritten to internal URLs (which Ivory is able to show when tapped), but something nicer would be REALLY cool... | 39 | | Polls | 🔶 | Polls can be viewed but cannot yet be created or voted on | 40 | | Bookmark Tweets | ❌ | Twitter API doesn't seem to expose bookmarked status properly | 41 | | Pinned Tweets | 🔶 | Pinned tweets will appear on profiles, but you cannot pin or unpin a tweet | 42 | | Circle Tweets | 🔶 | Circle tweets have a CW/spoiler attached to denote that they're special. Circle tweets cannot be posted yet | 43 | | Reply Controls | ❌ | Not sure how to translate this to Mastodon - I'm using the toot privacy flag to denote protected accounts | 44 | | Verified Display | ✅ | Verification status is shown as custom emoji | 45 | | Follow/Unfollow | ❌ | | 46 | | Block/Unblock | ❌ | Blocks will be respected but cannot be modified yet | 47 | | Disable Retweets | ❌ | Setting respected (this is handled by Twitter's servers) but cannot be modified yet | 48 | | Search | ❌ | | 49 | | Direct Messages | ❌❌❌ | Mastodon's DM paradigm is just too different to ever support these, honestly | 50 | | Follow Requests | ❌ | Not yet sure if this would fit into Mastodon's UI cleanly | 51 | 52 | ## TODO 53 | 54 | - Reorganise the codebase 55 | - Implement more features 56 | - Sort threads correctly 57 | - Implement more Twitter Card types as Mastodon cards where I can 58 | - Implement entities for profile descriptions (e.g. clickable @ names) 59 | - Implement missing bits in existing features (alt text, voting in polls, etc) 60 | - Find out why Elk is unhappy with my implementation of pagination 61 | - Forward info about the API rate limits 62 | - Redirect web browsers from the bridge's fake profile/tweet URLs to the real URLs on twitter.com 63 | - Add TypeScript types for more things (Twitter API and Mastodon API entities?) 64 | -------------------------------------------------------------------------------- /apis/authflow.ts: -------------------------------------------------------------------------------- 1 | // @deno-types="npm:@types/express@4.17.15" 2 | import express from "npm:express@4.18.2"; 3 | import twitter from "npm:twitter-text@3.1.0"; 4 | import {CONFIG} from "../config.ts"; 5 | import { crypto, toHashString } from "https://deno.land/std@0.173.0/crypto/mod.ts"; 6 | import * as base64 from "https://deno.land/std@0.173.0/encoding/base64.ts"; 7 | import {OAuth} from "../utils/oauth.ts"; 8 | 9 | // Add field to the Express request object 10 | declare global { 11 | namespace Express { 12 | export interface Request { 13 | oauth?: OAuth 14 | } 15 | } 16 | } 17 | 18 | const bridgeKey = await crypto.subtle.importKey( 19 | 'raw', 20 | new TextEncoder().encode(CONFIG.bridge_secret).buffer, 21 | {name: 'HMAC', hash: 'SHA-1'}, 22 | false, 23 | ['sign', 'verify'] 24 | ); 25 | 26 | async function packObject(obj: T): Promise { 27 | const json = JSON.stringify(obj); 28 | const bytes = new TextEncoder().encode(json).buffer; 29 | const signature = await crypto.subtle.sign('HMAC', bridgeKey, bytes); 30 | return btoa(json) + '|' + base64.encode(signature); 31 | } 32 | 33 | async function unpackObject(str: string): Promise { 34 | try { 35 | const bits = str.split('|'); 36 | if (bits.length == 2) { 37 | const json = atob(bits[0]); 38 | const bytes = new TextEncoder().encode(json).buffer; 39 | const signature = base64.decode(bits[1]); 40 | if (await crypto.subtle.verify('HMAC', bridgeKey, signature, bytes)) { 41 | return JSON.parse(json); 42 | } 43 | } 44 | } catch (ex) { 45 | console.error('Failed to unpack signed object', ex); 46 | } 47 | 48 | return null; 49 | } 50 | 51 | /// Data passed in a hidden, signed field when submitting the authorisation form 52 | interface SignInFormData { 53 | purpose: 'authorize', 54 | clientId: string, 55 | scope: string, 56 | redirectUri: string 57 | } 58 | 59 | /// Data passed in the 'code' field when the token is fetched by the client 60 | interface TokenRequestData { 61 | purpose: 'token', 62 | token: string, 63 | tokenSecret: string, 64 | clientId: string, 65 | scope: string 66 | } 67 | 68 | /// Authentication token pair for Twitter, as passed by a client 69 | interface TokenPair { 70 | a: string, 71 | s: string 72 | } 73 | 74 | async function makeClientSecret(clientId: string): Promise { 75 | const data = new TextEncoder().encode(CONFIG.bridge_secret + clientId).buffer; 76 | const hash = await crypto.subtle.digest('SHA-1', data); 77 | return toHashString(hash); 78 | } 79 | 80 | // All these endpoints can be accessed without authentication 81 | export function setup(app: express.Express) { 82 | app.post('/api/v1/apps', async (req, res) => { 83 | // Let the app believe that it's created something 84 | const client_id = btoa(req.body.client_name); 85 | const client_secret = await makeClientSecret(client_id); 86 | 87 | res.send({ 88 | id: '123', 89 | name: req.body.client_name, 90 | website: req.body.website, 91 | redirect_uri: req.body.redirect_uris, 92 | client_id, 93 | client_secret, 94 | vapid_key: '' // some clients want this field to exist 95 | }); 96 | }); 97 | 98 | app.get('/oauth/authorize', async (req, res) => { 99 | if ( 100 | typeof req.body.client_id !== 'string' || 101 | typeof req.body.redirect_uri !== 'string' || 102 | typeof req.body.scope !== 'string' 103 | ) { 104 | res.sendStatus(400); 105 | return; 106 | } 107 | 108 | const clientName = atob(req.body.client_id); 109 | const signedStuff = await packObject({ 110 | purpose: 'authorize', 111 | clientId: req.body.client_id, 112 | redirectUri: req.body.redirect_uri, 113 | scope: req.body.scope 114 | }); 115 | 116 | // Present a cool page 117 | res.send(` 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 |
126 |
127 |

Sign into ${twitter.htmlEscape(clientName)}

128 | 129 |
130 | 131 | 132 |
133 |
134 | 135 | 136 |
137 |
138 | 139 | 140 |
141 | 142 |
143 |
144 | 145 | `); 146 | }); 147 | 148 | app.post('/oauth/authorize', async (req, res) => { 149 | // Verify the stuff blob 150 | const stuff = await unpackObject(req.body.stuff); 151 | if (stuff && stuff.purpose === 'authorize' && req.body.bridgePassword === CONFIG.bridge_password) { 152 | // All good, build another signed blob to send as the 'code' 153 | const signedCode = await packObject({ 154 | purpose: 'token', 155 | token: req.body.token, 156 | tokenSecret: req.body.tokenSecret, 157 | clientId: stuff.clientId, 158 | scope: stuff.scope 159 | }); 160 | 161 | const url = new URL(stuff.redirectUri); 162 | url.searchParams.append('code', signedCode); 163 | res.redirect(url.toString()); 164 | return; 165 | } 166 | 167 | res.sendStatus(401); 168 | }); 169 | 170 | app.post('/oauth/token', async (req, res) => { 171 | // Verify the code blob 172 | if (req.body.grant_type === 'authorization_code') { 173 | const code = await unpackObject(req.body.code); 174 | if (code && code.purpose === 'token') { 175 | const clientSecret = await makeClientSecret(code.clientId); 176 | if (code.purpose === 'token' && req.body.client_id === code.clientId && req.body.client_secret === clientSecret) { 177 | // All good, create a token 178 | const token = await packObject({a: code.token, s: code.tokenSecret}); 179 | res.send({ 180 | 'access_token': token, 181 | 'token_type': 'Bearer', 182 | 'scope': code.scope, 183 | 'created_at': Math.floor(new Date().getTime() / 1000) 184 | }); 185 | return; 186 | } 187 | } 188 | } else if (req.body.grant_type === 'client_credentials') { 189 | // We can just return something generic here 190 | res.send({ 191 | 'access_token': 'GenericTokenToMakeTheOAuthFlowHappy', 192 | 'token_type': 'Bearer', 193 | 'scope': 'read write follow push', 194 | 'created_at': Math.floor(new Date().getTime() / 1000) 195 | }); 196 | return; 197 | } 198 | 199 | res.sendStatus(401); 200 | }); 201 | 202 | // Finally, after all non-authenticated routes, we add middleware for parsing OAuth tokens 203 | app.use(async (req, res, next) => { 204 | const auth = req.get('Authorization'); 205 | if (auth !== undefined && auth.startsWith('Bearer ')) { 206 | const token = await unpackObject(auth.substring(7)); 207 | if (token && token.a && token.s) { 208 | console.log('Successfully authenticated'); 209 | req.oauth = new OAuth( 210 | CONFIG.consumer_key, 211 | CONFIG.consumer_secret, 212 | token.a, 213 | token.s 214 | ); 215 | next(); 216 | return; 217 | } 218 | } 219 | 220 | res.sendStatus(401); 221 | }); 222 | } 223 | -------------------------------------------------------------------------------- /conversion.ts: -------------------------------------------------------------------------------- 1 | import twitter from "npm:twitter-text@3.1.0"; 2 | import { CONFIG } from "./config.ts"; 3 | import {BLUE_VERIFIED_EMOJI, IMAGE_1PX, PISS_VERIFIED_EMOJI, VERIFIED_EMOJI} from "./utils/apiUtil.ts"; 4 | 5 | const MONTHS: Record = { 6 | 'Jan': '01', 7 | 'Feb': '02', 8 | 'Mar': '03', 9 | 'Apr': '04', 10 | 'May': '05', 11 | 'Jun': '06', 12 | 'Jul': '07', 13 | 'Aug': '08', 14 | 'Sep': '09', 15 | 'Oct': '10', 16 | 'Nov': '11', 17 | 'Dec': '12' 18 | }; 19 | export function convertTimestamp(ts: string): string { 20 | const bits = /^... (...) (\d\d) (\d\d):(\d\d):(\d\d) \+.... (\d\d\d\d)$/.exec(ts); 21 | if (bits !== null) { 22 | const month = MONTHS[bits[1]]; 23 | const day = bits[2]; 24 | const hour = bits[3]; 25 | const minute = bits[4]; 26 | const second = bits[5]; 27 | const year = bits[6]; 28 | return `${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`; 29 | } else { 30 | return '1970-01-01T00:00:00.000Z'; 31 | } 32 | } 33 | export function convertPollTimestamp(ts: string): string { 34 | return ts.substring(0, ts.length - 1) + '.000Z'; 35 | } 36 | 37 | export function convertFormattedText(text: string, entities: Record, display_text_range: [number, number]): Record { 38 | // collate all entities into one list 39 | const list = []; 40 | if (entities.user_mentions) { 41 | for (const o of entities.user_mentions) { 42 | o.type = 'user_mention'; 43 | list.push(o); 44 | } 45 | } 46 | if (entities.urls) { 47 | for (const o of entities.urls) { 48 | o.type = 'url'; 49 | list.push(o); 50 | } 51 | } 52 | if (entities.hashtags) { 53 | for (const o of entities.hashtags) { 54 | o.type = 'hashtag'; 55 | list.push(o); 56 | } 57 | } 58 | // a fake 'end' entity 59 | list.push({type: 'end', indices: [display_text_range[1], display_text_range[1]]}); 60 | 61 | // add a space so that the library won't mangle the 'end' entity 62 | // that way, an emoji at the end of a tweet doesn't get cut off 63 | twitter.modifyIndicesFromUnicodeToUTF16(text + ' ', list); 64 | 65 | const output = []; 66 | const mentions = []; 67 | const tags = []; 68 | let lastPos = 0; 69 | let entityNum = 0; 70 | 71 | while (entityNum < list.length) { 72 | const entity = list[entityNum]; 73 | entityNum += 1; 74 | 75 | if (entity.indices[0] > lastPos) 76 | output.push(text.substring(lastPos, entity.indices[0])); 77 | 78 | if (entity.type === 'user_mention') { 79 | const url = `${CONFIG.root}/@${entity.screen_name}`; 80 | mentions.push({ 81 | id: entity.id_str, 82 | username: entity.screen_name, 83 | url, 84 | acct: entity.screen_name 85 | }); 86 | const urlEscape = twitter.htmlEscape(url); 87 | const username = twitter.htmlEscape(entity.screen_name); 88 | output.push(`@${username}`); 89 | } else if (entity.type === 'url') { 90 | // Remap tweet URLs 91 | const match = /^https:\/\/twitter.com\/([^/]+)\/status\/(\d+)/.exec(entity.expanded_url); 92 | if (match) { 93 | entity.expanded_url = `${CONFIG.root}/@${match[1]}/${match[2]}`; 94 | } 95 | const url = twitter.htmlEscape(entity.expanded_url); 96 | const displayURL = twitter.htmlEscape(entity.display_url); 97 | output.push(`${displayURL}`); 98 | } else if (entity.type === 'hashtag') { 99 | tags.push({ 100 | name: entity.text, 101 | url: 'https://twitter.com/tags/' + entity.text // TODO make this better 102 | }); 103 | const tag = twitter.htmlEscape(entity.text); 104 | output.push(`#${tag}`); 105 | } else if (entity.type === 'end') { 106 | break; 107 | } 108 | 109 | lastPos = entity.indices[1]; 110 | } 111 | 112 | return { 113 | content: output.join(''), 114 | mentions, 115 | tags 116 | }; 117 | } 118 | 119 | export function userToAccount(user: Record): Record { 120 | const account: Record = {}; 121 | 122 | account.id = user.id_str; 123 | account.username = user.screen_name; 124 | account.acct = user.screen_name; 125 | account.url = 'https://twitter.com/' + user.screen_name; 126 | account.display_name = user.name; 127 | account.note = user.description; 128 | account.avatar = user.profile_image_url_https.replace('_normal', ''); 129 | account.avatar_static = account.avatar; 130 | // TODO make this point to something useful 131 | // Pinafore just expects to see missing.png 132 | account.header = user.profile_banner_url || 'missing.png'; 133 | account.header_static = user.profile_banner_url || 'missing.png'; 134 | account.locked = user.protected; 135 | // fields, bot? 136 | account.created_at = convertTimestamp(user.created_at); 137 | if (user.status !== undefined) 138 | account.last_status_at = convertTimestamp(user.status.created_at); 139 | account.statuses_count = user.statuses_count; 140 | account.followers_count = user.followers_count; 141 | account.following_count = user.friends_count; 142 | account.emojis = []; 143 | 144 | if (user.ext_is_blue_verified) { 145 | account.emojis.push(BLUE_VERIFIED_EMOJI); 146 | account.display_name += ` :${BLUE_VERIFIED_EMOJI.shortcode}:`; 147 | } else if (user.verified) { 148 | if (user.ext_verified_type === 'Business') { 149 | account.emojis.push(PISS_VERIFIED_EMOJI); 150 | account.display_name += ` :${PISS_VERIFIED_EMOJI.shortcode}:`; 151 | } else { 152 | account.emojis.push(VERIFIED_EMOJI); 153 | account.display_name += ` :${VERIFIED_EMOJI.shortcode}:`; 154 | } 155 | } 156 | 157 | return account; 158 | } 159 | 160 | const MEDIA_TYPES: Record = { 161 | 'photo': 'image', 162 | 'video': 'video', 163 | 'animated_gif': 'gifv' 164 | }; 165 | 166 | export function convertMedia(media: Record): Record { 167 | const attachment: Record = {}; 168 | 169 | attachment.id = media.id_str; 170 | attachment.type = MEDIA_TYPES[media.type] || 'unknown'; 171 | attachment.url = media.media_url_https; 172 | attachment.preview_url = media.media_url_https; 173 | attachment.description = media.ext_alt_text; 174 | attachment.meta = { 175 | original: { 176 | width: media.original_info?.width, 177 | height: media.original_info?.height, 178 | size: `${media.original_info?.width}x${media.original_info?.height}`, 179 | aspect: media.original_info?.width / media.original_info?.height 180 | } 181 | }; 182 | 183 | if ((media.type === 'video' || media.type === 'animated_gif') && media.video_info?.variants) { 184 | // get the best-bitrate mp4 version 185 | let best = null; 186 | 187 | for (const variant of media.video_info.variants) { 188 | if (variant.content_type === 'video/mp4') { 189 | if (best === null || variant.bitrate > best.bitrate) 190 | best = variant; 191 | } 192 | } 193 | 194 | if (best) 195 | attachment.url = best.url; 196 | } 197 | 198 | return attachment; 199 | } 200 | 201 | function tryExpandURL(tcoUrl: string, extendedEntities: any): string { 202 | // search through the url entities in a tweet to try and turn a t.co url into a full url 203 | if (extendedEntities?.urls) { 204 | for (const entity of extendedEntities.urls) { 205 | if (entity.url === tcoUrl) 206 | return entity.expanded_url; 207 | } 208 | } 209 | 210 | // no luck 211 | return tcoUrl; 212 | } 213 | 214 | export function convertCard(card: Record, extendedEntities: any): Record { 215 | const pollMatch = card.name.match(/^poll(\d+)choice/); 216 | if (pollMatch) { 217 | const optionCount = parseInt(pollMatch[1], 10); 218 | const options = []; 219 | let totalVotes = 0; 220 | for (let i = 1; i <= optionCount; i++) { 221 | const votes = parseInt(card.binding_values[`choice${i}_count`].string_value, 10); 222 | options.push({ 223 | title: card.binding_values[`choice${i}_label`].string_value, 224 | votes_count: votes 225 | }); 226 | totalVotes += votes; 227 | } 228 | 229 | const ownVotes = []; 230 | if (card.binding_values.selected_choice?.string_value) 231 | ownVotes.push(parseInt(card.binding_values.selected_choice.string_value, 10) - 1); 232 | 233 | const poll = { 234 | id: card.url.replace(/^card:\/\//, ''), 235 | expires_at: convertPollTimestamp(card.binding_values.end_datetime_utc.string_value), 236 | expired: card.binding_values.counts_are_final.boolean_value, 237 | multiple: false, 238 | votes_count: totalVotes, 239 | voted: ownVotes.length > 0, 240 | own_votes: ownVotes, 241 | options, 242 | emojis: [] 243 | }; 244 | 245 | return {poll}; 246 | } else if (card.name === 'summary' || card.name === 'summary_large_image') { 247 | const newCard = { 248 | url: tryExpandURL(card.binding_values.card_url.string_value, extendedEntities), 249 | title: card.binding_values?.title?.string_value, 250 | description: card.binding_values?.description?.string_value, 251 | type: 'link', 252 | author_name: '', 253 | author_url: '', 254 | provider_name: '', 255 | provider_url: '', 256 | html: '', 257 | width: 1000, 258 | height: 1, 259 | // use a 1px image because Ivory won't render a card with no image 260 | image: IMAGE_1PX, 261 | embed_url: '', 262 | blurhash: null 263 | }; 264 | 265 | // the way that Ivory displays images is really obnoxious if they're square 266 | // so, I'm making an executive decision to only show them if they're not too tall 267 | try { 268 | const image = card.binding_values.thumbnail_image_large; 269 | const ratio = image.width / image.height; 270 | if (ratio >= 1.5) { 271 | newCard.width = image.width; 272 | newCard.height = image.height; 273 | newCard.image = image.url; 274 | } 275 | } catch (ex) { 276 | console.warn(`error parsing thumbnail_image_large in card of type ${card.name}:`, ex); 277 | } 278 | 279 | return {card: newCard}; 280 | } else { 281 | console.warn('Unhandled card', card.name); 282 | return {}; 283 | } 284 | } 285 | 286 | function convertTweetSource(source: string): Record | null { 287 | const match = /(.+)<\/a>/.exec(source); 288 | if (match) { 289 | return { name: match[2], website: match[1] }; 290 | } else { 291 | return null; 292 | } 293 | } 294 | 295 | export function tweetToToot(tweet: Record, globalObjects?: any): Record { 296 | const toot: Record = {}; 297 | 298 | if (tweet.user === undefined && globalObjects?.users) 299 | tweet.user = globalObjects.users[tweet.user_id_str]; 300 | 301 | toot.id = tweet.id_str; 302 | toot.uri = `https://twitter.com/${encodeURIComponent(tweet.user.screen_name)}/status/${encodeURIComponent(tweet.id_str)}`; 303 | toot.created_at = convertTimestamp(tweet.created_at); 304 | toot.account = userToAccount(tweet.user); 305 | toot.visibility = tweet.user.protected ? 'private' : 'public'; 306 | toot.sensitive = false; 307 | toot.spoiler_text = ''; 308 | toot.media_attachments = []; 309 | toot.application = convertTweetSource(tweet.source); 310 | if (tweet.retweeted_status !== undefined) { 311 | toot.reblog = tweetToToot(tweet.retweeted_status); 312 | toot.in_reply_to_id = null; 313 | toot.in_reply_to_account_id = null; 314 | toot.language = null; 315 | toot.url = null; 316 | toot.replies_count = 0; 317 | toot.reblogs_count = 0; 318 | toot.favourites_count = 0; 319 | toot.content = ''; 320 | toot.text = ''; 321 | if (tweet.retweeted_status.is_quote_status) { 322 | // pull out the QT card for Ivory 323 | toot.card = toot.reblog.card; 324 | } 325 | } else { 326 | toot.reblog = null; 327 | toot.in_reply_to_id = tweet.in_reply_to_status_id_str; 328 | toot.in_reply_to_account_id = tweet.in_reply_to_user_id_str; 329 | toot.language = tweet.lang; 330 | toot.url = toot.uri; 331 | toot.replies_count = tweet.reply_count; 332 | toot.reblogs_count = tweet.retweet_count; 333 | toot.favourites_count = tweet.favorite_count; 334 | const conv = convertFormattedText(tweet.full_text, tweet.entities, tweet.display_text_range); 335 | toot.content = conv.content; 336 | toot.mentions = conv.mentions; 337 | toot.tags = conv.tags; 338 | toot.text = tweet.full_text; 339 | if (tweet?.extended_entities?.media) { 340 | toot.media_attachments = tweet.extended_entities.media.map(convertMedia); 341 | } 342 | 343 | // append quoted tweets, in lieu of a better option 344 | if (tweet.is_quote_status && tweet.quoted_status_permalink) { 345 | const quote = tweet.quoted_status; 346 | const quoteLink = tweet.quoted_status_permalink; 347 | const match = /^https:\/\/twitter.com\/([^/]+)\/status\/(\d+)/.exec(quoteLink.expanded); 348 | if (match) { 349 | // rewriting the URL like this makes it clickable in Ivory 350 | quoteLink.expanded = `${CONFIG.root}/@${match[1]}/${match[2]}`; 351 | } 352 | 353 | // can i use a card here? 354 | if (quote) { 355 | // annoyingly, Ivory won't show the description, which makes this far less useful than it could be 356 | toot.card = { 357 | url: quoteLink.expanded, 358 | title: quote.user.name ? `🔁 ${quote.user.name} (@${quote.user.screen_name})` : `🔁 @${quote.user.screen_name}`, 359 | description: quote.full_text, 360 | type: 'link', 361 | author_name: '', 362 | author_url: '', 363 | provider_name: '', 364 | provider_url: '', 365 | html: '', 366 | width: 1000, 367 | height: 1, 368 | // use a 1px image because Ivory won't render a card with no image 369 | image: IMAGE_1PX, 370 | embed_url: '', 371 | blurhash: null 372 | }; 373 | } 374 | 375 | // always append a regular link because Ivory demands to see one anyway 376 | // (unless there already was one!) 377 | const url = twitter.htmlEscape(quoteLink.expanded); 378 | if (!toot.content || !toot.content.includes(url)) { 379 | const displayURL = twitter.htmlEscape(quoteLink.display); 380 | toot.content = toot.content + ` ${displayURL}`; 381 | } 382 | } 383 | } 384 | toot.favourited = tweet.favorited; 385 | toot.reblogged = tweet.retweeted; 386 | 387 | if (tweet.card) { 388 | const conv = convertCard(tweet.card, tweet.entities); 389 | if (conv.poll) 390 | toot.poll = conv.poll; 391 | if (conv.card && !toot.card) 392 | toot.card = conv.card; 393 | } 394 | 395 | if (tweet.limited_actions === 'limit_trusted_friends_tweet') { 396 | const whomst = tweet.ext_trusted_friends_metadata?.metadata?.owner_screen_name || '???'; 397 | toot.spoiler_text = `🔵 ${whomst}'s circle`; 398 | } 399 | 400 | return toot; 401 | } 402 | 403 | export function activityToNotification(activity: Record): Record | null { 404 | const note: Record = {}; 405 | 406 | note.id = activity.max_position; 407 | note.created_at = convertTimestamp(activity.created_at); 408 | if (activity.action === 'favorite') { 409 | note.type = 'favourite'; 410 | note.status = tweetToToot(activity.targets[0]); 411 | note.account = userToAccount(activity.sources[0]); 412 | } else if (activity.action === 'reply') { 413 | note.type = 'mention'; 414 | note.status = tweetToToot(activity.targets[0]); 415 | note.account = userToAccount(activity.sources[0]); 416 | } else if (activity.action === 'mention') { 417 | note.type = 'mention'; 418 | note.status = tweetToToot(activity.target_objects[0]); 419 | note.account = userToAccount(activity.sources[0]); 420 | } else if (activity.action === 'retweet') { 421 | note.type = 'reblog'; 422 | note.status = tweetToToot(activity.targets[0]); 423 | note.account = userToAccount(activity.sources[0]); 424 | } else if (activity.action === 'follow') { 425 | note.type = 'follow'; 426 | note.account = userToAccount(activity.sources[0]); 427 | } else { 428 | console.warn('unhandled activity', activity); 429 | note.type = 'invalid'; 430 | return note; 431 | } 432 | 433 | return note; 434 | } 435 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | // @deno-types="npm:@types/express@4.17.15" 2 | import express from "npm:express@4.18.2"; 3 | import "npm:express-async-errors@3.1.1"; 4 | import multer from "npm:multer@1.4.5-lts.1"; 5 | import cors from "npm:cors@2.8.5"; 6 | import {userToAccount, tweetToToot, activityToNotification} from "./conversion.ts"; 7 | import {OAuth} from "./utils/oauth.ts"; 8 | import { 9 | addPageLinksToResponse, 10 | BLUE_VERIFIED_EMOJI, 11 | buildParams, 12 | injectPagingInfo, 13 | PISS_VERIFIED_EMOJI, VERIFIED_EMOJI 14 | } from "./utils/apiUtil.ts"; 15 | import {UserCache} from "./utils/userCache.ts"; 16 | import {CONFIG} from "./config.ts"; 17 | import {setup as setupAuthflow} from "./apis/authflow.ts"; 18 | 19 | const userCacheMap = new Map(); 20 | function getUserCache(oauth: OAuth): UserCache { 21 | let cache = userCacheMap.get(oauth.accessToken); 22 | if (cache) 23 | return cache; 24 | cache = new UserCache(oauth); 25 | userCacheMap.set(oauth.accessToken, cache); 26 | return cache; 27 | } 28 | 29 | const app = express(); 30 | const upload = multer(); 31 | app.use(upload.none()); 32 | 33 | declare global { 34 | namespace Express { 35 | export interface Request { 36 | originalJsonBody?: Uint8Array 37 | } 38 | } 39 | } 40 | app.use(express.json({ 41 | verify: (req, _res, body, _encoding) => { 42 | // a terrible, terrible hack that lets us get the original JSON later 43 | req.originalJsonBody = body; 44 | } 45 | })); 46 | 47 | app.use(express.urlencoded({ extended: true })); 48 | app.use(cors()); 49 | app.use('/static', express.static(new URL('static', import.meta.url).pathname)); 50 | 51 | app.use((req, res, next) => { 52 | // Inject query params into the body 53 | if (req.body === null) 54 | req.body = {}; 55 | for (const [key, value] of Object.entries(req.query)) { 56 | req.body[key] = value; 57 | } 58 | 59 | console.log('Request to', req.url); 60 | console.log('body:', req.body); 61 | next(); 62 | }); 63 | 64 | app.get('/api/v1/instance', (req, res) => { 65 | res.send({ 66 | uri: CONFIG.domain, 67 | title: 'Twitter', 68 | short_description: 'A lazy bridge to Twitter', 69 | description: 'A lazy bridge to Twitter', 70 | email: 'example@example.com', 71 | version: '0.0.1', 72 | urls: { 73 | streaming_api: '' 74 | }, 75 | stats: { 76 | user_count: 1, 77 | status_count: 99999, 78 | domain_count: 1 79 | }, 80 | // no thumbnail 81 | languages: ['en'], 82 | registrations: false, 83 | approval_required: true, 84 | invites_enabled: false, 85 | configuration: { 86 | accounts: { 87 | max_featured_tags: 0 88 | }, 89 | statuses: { 90 | max_characters: 280, 91 | max_media_attachments: 4, 92 | characters_reserved_per_url: 23 // FIXME 93 | }, 94 | polls: { 95 | max_options: 4, 96 | max_characters_per_option: 20, // FIXME 97 | min_expiration: 1, // FIXME 98 | max_expiration: 100000 // FIXME 99 | } 100 | // TODO: media_attachments 101 | }, 102 | // TODO: contact_account 103 | rules: [] 104 | }); 105 | }); 106 | 107 | setupAuthflow(app); 108 | // All routes added after this will require a valid OAuth token 109 | 110 | app.get('/api/v1/accounts/verify_credentials', async (req, res) => { 111 | const twreq = await req.oauth!.request('GET', 'https://api.twitter.com/1.1/account/verify_credentials.json'); 112 | const user = await twreq.json(); 113 | 114 | const account = userToAccount(user); 115 | account.source = { 116 | privacy: user.protected ? 'private' : 'public', 117 | note: user.description 118 | }; 119 | 120 | res.send(account); 121 | }); 122 | 123 | app.get('/api/v1/timelines/home', async (req, res) => { 124 | const url = 'https://api.twitter.com/1.1/statuses/home_timeline.json'; 125 | const params: Record = buildParams(true); 126 | params.include_my_retweet = '1'; 127 | injectPagingInfo(req.body, params); 128 | 129 | // The Mastodon API offers more flexibility in paging than Twitter does, so we need to 130 | // play games in order to get refreshing in Ivory to work. 131 | 132 | // If you reopen Ivory and there are 300 new posts, it tries to fetch them in order 133 | // from oldest to newest, by passing "min_id=X" where X is the most recent post it 134 | // saw. This doesn't work with Twitter - if we pass "since_id=X", we get the newest 135 | // 40 tweets. 136 | 137 | // Tweetbot has logic to detect this and fill the gap, but Ivory doesn't include it. 138 | // Thankfully, Ivory is okay with receiving more posts than it requested - so we can 139 | // just detect this case and do the backfilling ourselves. 140 | 141 | let tweets; 142 | if (req.body.min_id !== undefined && req.body.max_id === undefined && req.body.since_id === undefined) { 143 | // Ivory "get the latest posts" case detected 144 | tweets = []; 145 | 146 | const lastRead = BigInt(req.body.min_id as string); 147 | let maxID: BigInt | null = null; 148 | params.count = '200'; // we may as well load Twitter's maximum and save on requests! 149 | params.since_id = (lastRead - 1n).toString(); // fetch the last read tweet as well 150 | let done = false; 151 | 152 | console.log(`Tweet update request from ${lastRead} onwards`); 153 | 154 | while (!done) { 155 | let thisBatch; 156 | try { 157 | if (maxID !== null) 158 | params.max_id = maxID.toString(); 159 | 160 | const twreq = await req.oauth!.get(url, params); 161 | thisBatch = await twreq.json(); 162 | } catch (ex) { 163 | console.error('Error while loading tweets', ex); 164 | break; 165 | } 166 | 167 | for (const tweet of thisBatch) { 168 | const id = BigInt(tweet.id_str); 169 | if (id <= lastRead) { 170 | // We now know we have everything 171 | console.log(`LastRead tweet ID seen, so we're done`); 172 | done = true; 173 | break; 174 | } 175 | 176 | if (maxID === null || id < maxID) 177 | maxID = id - 1n; 178 | 179 | tweets.push(tweet); 180 | } 181 | 182 | console.log(`Loaded ${thisBatch.length} tweets (total now ${tweets.length}), new maxID=${maxID}`); 183 | 184 | // We requested 200 tweets, but because of filtering, we might not actually get 185 | // that many. So, if we got 150 or more (and we didn't see the 'last read' tweet), 186 | // we make another request. Otherwise, we bail. 187 | if (thisBatch.length < 150) { 188 | console.log(`Batch was under 150 tweets, so assume this is the end`); 189 | done = true; 190 | } 191 | } 192 | 193 | // For debugging, grab the IDs and dates of the oldest and newest tweets in this bundle 194 | let oldest = null, newest = null; 195 | for (const tweet of tweets) { 196 | if (oldest === null || BigInt(tweet.id_str) < BigInt(oldest.id_str)) 197 | oldest = tweet; 198 | if (newest === null || BigInt(tweet.id_str) > BigInt(newest.id_str)) 199 | newest = tweet; 200 | } 201 | 202 | console.log(`Returning ${tweets.length} tweets (${oldest?.id_str}, ${oldest?.created_at} -> ${newest?.id_str}, ${newest?.created_at})`); 203 | } else { 204 | // Stick to the original logic 205 | const twreq = await req.oauth!.get(url, params); 206 | tweets = await twreq.json(); 207 | } 208 | 209 | const toots = tweets.map(tweetToToot); 210 | addPageLinksToResponse(new URL(req.originalUrl, CONFIG.root), toots as {id: string}[], res); 211 | res.send(toots); 212 | }); 213 | 214 | function isMentionsTimelineQuery(data: any): boolean { 215 | if (data.types && Array.isArray(data.types)) { 216 | // for Ivory 217 | if ((data.types.length === 1 && data.types[0] === 'mention') || 218 | (data.types.length === 2 && data.types[0] === 'mention' && data.types[1] === 'mention')) 219 | return true; 220 | } else if (data.exclude_types && Array.isArray(data.exclude_types)) { 221 | // for Pinafore 222 | const check = [ 223 | 'follow', 'favourite', 'reblog', 'poll', 224 | 'admin.sign_up', 'update', 'follow_request', 'admin.report' 225 | ]; 226 | if (data.exclude_types.length === check.length) { 227 | for (let i = 0; i < check.length; i++) { 228 | if (check[i] !== data.exclude_types[i]) 229 | return false; 230 | } 231 | return true; 232 | } 233 | } 234 | 235 | return false; 236 | } 237 | 238 | app.get('/api/v1/notifications', async (req, res) => { 239 | const params: Record = buildParams(true); 240 | injectPagingInfo(req.body, params); 241 | 242 | if (isMentionsTimelineQuery(req.body)) { 243 | // special case for 'mentions' timeline 244 | const twreq = await req.oauth!.request('GET', 'https://api.twitter.com/1.1/statuses/mentions_timeline.json', params); 245 | const mentions = await twreq.json(); 246 | const notifications = []; 247 | 248 | for (const mention of mentions) { 249 | const toot = tweetToToot(mention); 250 | notifications.push({ 251 | account: toot.account, 252 | created_at: toot.created_at, 253 | id: toot.id, 254 | status: toot, 255 | type: 'mention' 256 | }); 257 | } 258 | 259 | addPageLinksToResponse(new URL(req.originalUrl, CONFIG.root), notifications as { id: string }[], res); 260 | res.send(notifications); 261 | } else { 262 | // fetch the full notification feed 263 | // no filtering yet, i should probably fix that 264 | params.skip_aggregation = 'true'; 265 | 266 | const twreq = await req.oauth!.request('GET', 'https://api.twitter.com/1.1/activity/about_me.json', params); 267 | const activities = await twreq.json(); 268 | 269 | const notifications = []; 270 | for (const activity of activities) { 271 | const notification = activityToNotification(activity); 272 | if (notification !== null) 273 | notifications.push(notification); 274 | } 275 | 276 | addPageLinksToResponse(new URL(req.originalUrl, CONFIG.root), notifications as { id: string }[], res); 277 | 278 | res.send(notifications.filter(n => n.type !== 'invalid')); 279 | } 280 | }); 281 | 282 | app.get('/api/v1/follow_requests', (req, res) => { 283 | res.send([]); 284 | }); 285 | 286 | app.get('/api/v1/custom_emojis', (req, res) => { 287 | res.send([VERIFIED_EMOJI, BLUE_VERIFIED_EMOJI, PISS_VERIFIED_EMOJI]); 288 | }); 289 | 290 | app.get('/api/v1/filters', (req, res) => { 291 | res.send([]); 292 | }); 293 | 294 | app.get('/api/v1/lists', async (req, res) => { 295 | const twreq = await req.oauth!.request( 296 | 'GET', 297 | 'https://api.twitter.com/1.1/lists/list.json', 298 | {user_id: req.oauth!.myID} 299 | ); 300 | const twitterLists = await twreq.json(); 301 | const lists = []; 302 | 303 | for (const twitterList of twitterLists) { 304 | lists.push({ 305 | id: twitterList.id_str, 306 | title: twitterList.name, 307 | replies_policy: 'none' 308 | }); 309 | } 310 | 311 | res.send(lists); 312 | }); 313 | 314 | app.get('/api/v1/timelines/list/:list_id(\\d+)', async (req, res) => { 315 | const params: Record = buildParams(true); 316 | params.list_id = req.params.list_id; 317 | injectPagingInfo(req.body, params); 318 | 319 | const twreq = await req.oauth!.request('GET', 'https://api.twitter.com/1.1/lists/statuses.json', params); 320 | const tweets = await twreq.json(); 321 | const toots = tweets.map(tweetToToot); 322 | addPageLinksToResponse(new URL(req.originalUrl, CONFIG.root), toots as {id: string}[], res); 323 | res.send(toots); 324 | }); 325 | 326 | app.get('/api/v1/accounts/:id(\\d+)', async (req, res) => { 327 | const userCache = getUserCache(req.oauth!); 328 | const user = await userCache.fetchUser(req.params.id); 329 | res.send(userToAccount(user)); 330 | }); 331 | 332 | app.get('/api/v1/accounts/:id(\\d+)/statuses', async (req, res) => { 333 | if (req.body.pinned) { 334 | const userCache = getUserCache(req.oauth!); 335 | const user = await userCache.fetchUser(req.params.id); 336 | const pinned = []; 337 | if (user.pinned_tweet_ids_str) { 338 | const params = buildParams(true); 339 | params.id = user.pinned_tweet_ids_str.join(','); 340 | const twreq = await req.oauth!.request('GET', 'https://api.twitter.com/1.1/statuses/lookup.json', params); 341 | const map = new Map(); 342 | for (const tweet of await twreq.json()) { 343 | map.set(tweet.id_str, tweet); 344 | } 345 | for (const id of user.pinned_tweet_ids_str) { 346 | const tweet = map.get(id); 347 | if (tweet !== undefined) 348 | pinned.push(tweetToToot(tweet)); 349 | } 350 | } 351 | res.send(pinned); 352 | return; 353 | } 354 | 355 | const params = buildParams(true); 356 | params.user_id = req.params.id; 357 | injectPagingInfo(req.body, params); 358 | 359 | const twreq = await req.oauth!.request('GET', 'https://api.twitter.com/1.1/statuses/user_timeline.json', params); 360 | const tweets = await twreq.json(); 361 | const toots = tweets.map(tweetToToot); 362 | addPageLinksToResponse(new URL(req.originalUrl, CONFIG.root), toots as {id: string}[], res); 363 | res.send(toots); 364 | }); 365 | 366 | app.get('/api/v1/accounts/relationships', async (req, res) => { 367 | const results = []; 368 | 369 | if (req.body.id) { 370 | const ids = Array.isArray(req.body.id) ? req.body.id : [req.body.id]; 371 | 372 | if (ids.length > 1) 373 | console.warn(`WARNING: Got relationships query with ${ids.length} IDs`); 374 | 375 | for (const id of ids) { 376 | if (typeof id === 'string') { 377 | const userCache = getUserCache(req.oauth!); 378 | const user = await userCache.fetchUser(id); 379 | results.push({ 380 | id: user.id_str, 381 | following: user.following, 382 | showing_reblogs: false, // todo 383 | notifying: user.notifications, 384 | followed_by: user.followed_by, 385 | blocking: false, // todo 386 | blocked_by: false, // todo 387 | muting: false, 388 | muting_notifications: false, 389 | requested: user.follow_request_sent, 390 | domain_blocking: false, 391 | endorsed: false, 392 | note: '' 393 | }); 394 | } 395 | } 396 | } 397 | 398 | res.send(results); 399 | }); 400 | 401 | app.get('/api/v1/statuses/:id(\\d+)', async (req, res) => { 402 | const params = buildParams(true); 403 | params.id = req.params.id; 404 | const twreq = await req.oauth!.request('GET', 'https://api.twitter.com/1.1/statuses/show.json', params); 405 | const tweet = await twreq.json(); 406 | if (twreq.status === 200) { 407 | res.send(tweetToToot(tweet)); 408 | } else { 409 | res.status(twreq.status).send({error: JSON.stringify(tweet)}); 410 | } 411 | }); 412 | 413 | app.get('/api/v1/statuses/:id(\\d+)/context', async (req, res) => { 414 | const id = BigInt(req.params.id as string); 415 | 416 | const params = buildParams(true); 417 | const twreq = await req.oauth!.request('GET', `https://api.twitter.com/2/timeline/conversation/${id.toString()}.json`, params); 418 | const conversation = await twreq.json(); 419 | 420 | const ancestors = []; 421 | const descendants = []; 422 | 423 | for (const obj of Object.values(conversation.globalObjects.tweets)) { 424 | const tweet = obj as Record; 425 | const checkID = BigInt(tweet.id_str); 426 | if (checkID < id) 427 | ancestors.push(tweetToToot(tweet, conversation.globalObjects)); 428 | else if (checkID > id) 429 | descendants.push(tweetToToot(tweet, conversation.globalObjects)); 430 | } 431 | 432 | ancestors.sort((a, b) => { 433 | const aID = BigInt(a.id); 434 | const bID = BigInt(b.id); 435 | if (aID < bID) 436 | return -1; 437 | if (aID > bID) 438 | return 1; 439 | return 0; 440 | }); 441 | 442 | descendants.sort((a, b) => { 443 | const aID = BigInt(a.id); 444 | const bID = BigInt(b.id); 445 | if (aID < bID) 446 | return -1; 447 | if (aID > bID) 448 | return 1; 449 | return 0; 450 | }); 451 | 452 | res.send({ ancestors, descendants }); 453 | }); 454 | 455 | app.get('/api/v2/search', async (req, res) => { 456 | // Ivory uses this to resolve an unknown toot 457 | if (req.body.limit == '1' && req.body.resolve == '1' && req.body.type === 'statuses') { 458 | const match = /^(.+)\/@([^/]+)\/(\d+)$/.exec(req.body.q as string); 459 | if (match && match[1] === CONFIG.root) { 460 | const params = buildParams(true); 461 | params.id = match[3]; 462 | const twreq = await req.oauth!.request('GET', 'https://api.twitter.com/1.1/statuses/show.json', params); 463 | const tweet = await twreq.json(); 464 | res.send({accounts: [], hashtags: [], statuses: [tweetToToot(tweet)]}); 465 | return; 466 | } 467 | } 468 | 469 | res.sendStatus(404); 470 | }); 471 | 472 | app.post('/api/v1/statuses', async (req, res) => { 473 | const text = req.body.status || ''; 474 | let reply_target = req.body.in_reply_to_id; 475 | 476 | if (typeof reply_target === 'number' && req.originalJsonBody) { 477 | // this is an out-of-spec request from Ivory, so... 478 | // there's a high chance that JSON.parse has mangled the tweet ID 479 | const json = new TextDecoder().decode(req.originalJsonBody); 480 | const match = json.match(/"in_reply_to_id":\s*(\d+)/); 481 | if (match) { 482 | console.log(`Received numeric in_reply_to_id: ${reply_target}, replacing with: ${match[1]}`) 483 | reply_target = match[1]; 484 | } else { 485 | console.warn('Failed to fix in_reply_to_id number', json); 486 | } 487 | } 488 | 489 | /* 490 | tweet vars from web client: (* = publicly documented) 491 | *status 492 | *card_uri 493 | *attachment_url (for quote tweets) 494 | *in_reply_to_status_id 495 | geo 496 | preview 497 | conversation_control 498 | exclusive_tweet_control_options (super follow related) 499 | trusted_friends_control_options[trusted_friends_list_id] (circles) 500 | previous_tweet_id (editing) 501 | semantic_annotation_ids (wtf is this?) 502 | batch_mode (enum for threading) 503 | *exclude_reply_user_ids (use with auto_populate_reply_metadata) 504 | promotedContent 505 | *media_ids 506 | media_tags 507 | */ 508 | const params = buildParams(true); 509 | params.status = text; 510 | if (reply_target) { 511 | // Let's not set this for now because Ivory seems to include the @ name in the toot 512 | //params.auto_populate_reply_metadata = 'true'; 513 | params.in_reply_to_status_id = reply_target; 514 | } 515 | 516 | const twreq = await req.oauth!.post('https://api.twitter.com/1.1/statuses/update.json', params); 517 | const tweet = await twreq.json(); 518 | if (twreq.status === 200) { 519 | res.send(tweetToToot(tweet)); 520 | } else { 521 | // TODO: better/more consistent handling of errors... 522 | res.status(twreq.status).send({error: JSON.stringify(tweet)}); 523 | } 524 | }); 525 | 526 | app.delete('/api/v1/statuses/:id(\\d+)', async (req, res) => { 527 | const params = buildParams(true); 528 | const twreq = await req.oauth!.post(`https://api.twitter.com/1.1/statuses/destroy/${req.params.id}.json`, params); 529 | const tweet = await twreq.json(); 530 | if (twreq.status === 200) { 531 | res.send(tweetToToot(tweet)); 532 | } else { 533 | res.status(twreq.status).send({error: JSON.stringify(tweet)}); 534 | } 535 | }); 536 | 537 | app.post('/api/v1/statuses/:id(\\d+)/favourite', async (req, res) => { 538 | const params = buildParams(true); 539 | params.id = req.params.id; 540 | const twreq = await req.oauth!.post('https://api.twitter.com/1.1/favorites/create.json', params); 541 | const tweet = await twreq.json(); 542 | if (twreq.status === 200) { 543 | res.send(tweetToToot(tweet)); 544 | } else { 545 | res.status(twreq.status).send({error: JSON.stringify(tweet)}); 546 | } 547 | }); 548 | 549 | app.post('/api/v1/statuses/:id(\\d+)/unfavourite', async (req, res) => { 550 | const params = buildParams(true); 551 | params.id = req.params.id; 552 | const twreq = await req.oauth!.post('https://api.twitter.com/1.1/favorites/destroy.json', params); 553 | const tweet = await twreq.json(); 554 | if (twreq.status === 200) { 555 | res.send(tweetToToot(tweet)); 556 | } else { 557 | res.status(twreq.status).send({error: JSON.stringify(tweet)}); 558 | } 559 | }); 560 | 561 | app.post('/api/v1/statuses/:id(\\d+)/reblog', async (req, res) => { 562 | const params = buildParams(true); 563 | const twreq = await req.oauth!.post(`https://api.twitter.com/1.1/statuses/retweet/${req.params.id}.json`, params); 564 | const tweet = await twreq.json(); 565 | if (twreq.status === 200) { 566 | res.send(tweetToToot(tweet)); 567 | } else { 568 | res.status(twreq.status).send({error: JSON.stringify(tweet)}); 569 | } 570 | }); 571 | 572 | app.post('/api/v1/statuses/:id(\\d+)/unreblog', async (req, res) => { 573 | const params = buildParams(true); 574 | const twreq = await req.oauth!.post(`https://api.twitter.com/1.1/statuses/unretweet/${req.params.id}.json`, params); 575 | const tweet = await twreq.json(); 576 | if (twreq.status === 200) { 577 | res.send(tweetToToot(tweet)); 578 | } else { 579 | res.status(twreq.status).send({error: JSON.stringify(tweet)}); 580 | } 581 | }); 582 | 583 | app.get('/api/v1/accounts/search', async (req, res) => { 584 | // Ivory uses this to resolve a user by name 585 | if (req.body.limit == '1' && req.body.resolve == '1') { 586 | const match = /^([^@]+)@([^@]+)$/.exec(req.body.q as string); 587 | if (match && match[2] === CONFIG.domain) { 588 | const params = buildParams(true); 589 | params.screen_name = match[1]; 590 | const twreq = await req.oauth!.request('GET', 'https://api.twitter.com/1.1/users/show.json', params); 591 | const user = await twreq.json(); 592 | if (twreq.status === 200) { 593 | res.send([userToAccount(user)]); 594 | } else { 595 | res.status(twreq.status).send({error: JSON.stringify(user)}); 596 | } 597 | return; 598 | } 599 | } 600 | 601 | res.sendStatus(404); 602 | }); 603 | 604 | app.listen(8000); 605 | --------------------------------------------------------------------------------