├── src ├── types │ ├── twit-module.d.ts │ ├── twit.d.ts │ ├── api.d.ts │ ├── twitter │ │ ├── media.d.ts │ │ └── tweet.d.ts │ ├── utils.d.ts │ └── sketch.d.ts ├── config.ts ├── util │ ├── secrets.ts │ ├── logger.ts │ ├── replies.ts │ └── common.ts ├── sketch │ ├── definitions.ts │ └── index.ts ├── bot.ts └── index.ts ├── test ├── setup │ └── jest.setup.js ├── parser.test.ts ├── replies.test.ts ├── sketch.test.ts ├── utils.test.ts └── mocks │ └── tweets.ts ├── screen_setup.sh ├── babel.config.js ├── jest.config.js ├── .prettierrc ├── LICENSE ├── README-en-us.md ├── README.md ├── package.json ├── .gitignore └── tsconfig.json /src/types/twit-module.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'twit'; 2 | -------------------------------------------------------------------------------- /test/setup/jest.setup.js: -------------------------------------------------------------------------------- 1 | import '../../src/util/secrets'; -------------------------------------------------------------------------------- /screen_setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo nohup Xvfb :1 -screen 0 1024x768x24 & 4 | export DISPLAY=":1" -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ] 6 | } -------------------------------------------------------------------------------- /src/types/twit.d.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'http'; 2 | 3 | export interface Response { 4 | data: T; 5 | err?: Error; 6 | resp: IncomingMessage; 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | coverageDirectory: "coverage", 4 | setupFiles: ["\\test\\setup\\jest.setup.js"], 5 | testEnvironment: "node", 6 | }; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "tabWidth": 2, 5 | "bracketSpacing": true, 6 | "arrowParens": "avoid", 7 | "printWidth": 100, 8 | "trailingComma": "es5" 9 | } 10 | -------------------------------------------------------------------------------- /src/types/api.d.ts: -------------------------------------------------------------------------------- 1 | export interface Params { 2 | status?: string; 3 | media_ids?: string[]; 4 | in_reply_to_status_id?: string; 5 | id?: string; 6 | tweet_mode?: 'extended'; 7 | media_data?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | const keys = { 2 | consumer_key: process.env.CONSUMER_KEY, 3 | consumer_secret: process.env.CONSUMER_SECRET, 4 | access_token: process.env.ACCESS_TOKEN, 5 | access_token_secret: process.env.ACCESS_TOKEN_SECRET, 6 | }; 7 | 8 | export { keys }; 9 | -------------------------------------------------------------------------------- /src/types/twitter/media.d.ts: -------------------------------------------------------------------------------- 1 | export interface UploadedMedia { 2 | media_id: number; 3 | media_id_string: string; 4 | media_key: string; 5 | size: number; 6 | expires_after_secs: number; 7 | image: Image; 8 | } 9 | 10 | export interface Image { 11 | image_type: string; 12 | w: number; 13 | h: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/types/utils.d.ts: -------------------------------------------------------------------------------- 1 | import { LogEntry } from 'winston'; 2 | 3 | export interface File { 4 | name: string; 5 | extension: string; 6 | } 7 | 8 | export interface Log extends LogEntry { 9 | id?: string; 10 | } 11 | 12 | interface ExtendedError extends Error { 13 | id?: string; 14 | } 15 | 16 | export interface Configuration { 17 | [key: string]: number; 18 | } 19 | 20 | export interface ValueValidation { 21 | status: 'success' | 'error'; 22 | prop: string; 23 | type: 'allowed' | 'range'; 24 | } -------------------------------------------------------------------------------- /src/util/secrets.ts: -------------------------------------------------------------------------------- 1 | import logger from './logger'; 2 | import dotenv from 'dotenv'; 3 | import { existsSync } from 'fs'; 4 | 5 | if (existsSync('.env') && !existsSync('.env.test')) { 6 | logger.debug('Using production environment variables'); 7 | dotenv.config({ path: '.env' }); 8 | } else if (existsSync('.env.test')) { 9 | logger.debug('Using development environment variables'); 10 | dotenv.config({ path: '.env.test' }); 11 | } else { 12 | logger.error('No .env file found'); 13 | process.exit(1); 14 | } 15 | 16 | export const ENVIRONMENT = process.env.NODE_ENV; 17 | -------------------------------------------------------------------------------- /src/types/sketch.d.ts: -------------------------------------------------------------------------------- 1 | import { PSketchesEnum } from '../../src/sketch/index'; 2 | import { Configuration } from './utils'; 3 | 4 | export type SketchName = keyof typeof PSketchesEnum; 5 | export type ValueType = 'range' | 'allowed'; 6 | export type ValueBoundary = number[] | [number, number]; 7 | 8 | export type SketchConfig = { 9 | name: SketchName; 10 | parameters: string[]; 11 | values: ParameterValues; 12 | defaultConfig: Configuration; 13 | }; 14 | 15 | export interface ParameterValues { 16 | [key: string]: { 17 | boundaries: ValueBoundary; 18 | type: ValueType; 19 | }; 20 | } -------------------------------------------------------------------------------- /test/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { parseConfig } from '../src/util/common'; 2 | 3 | test('parseia as opções informadas pelo usuário', () => { 4 | const subjects = [ 5 | { 6 | input: 'mode=5 photo=1', 7 | output: { mode: 5, photo: 1 } 8 | }, 9 | { 10 | input: 'photo=2', 11 | output: { photo: 2 } 12 | }, 13 | { 14 | input: 'a=3 b=5', 15 | output: { a: 3, b: 5 } 16 | } 17 | ]; 18 | 19 | const expected = subjects.map(sub => sub.output); 20 | const results = subjects.map(sub => parseConfig(sub.input)); 21 | expect(expected).toStrictEqual(results); 22 | }) -------------------------------------------------------------------------------- /src/util/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import { format } from 'winston'; 3 | const { combine, timestamp, printf } = format; 4 | 5 | const logFormat = printf( 6 | ({ level, message, timestamp, id }) => 7 | `${timestamp} [${level.toUpperCase()}] (${id ?? 'No ID'}) ${message}` 8 | ); 9 | 10 | const options: winston.LoggerOptions = { 11 | transports: [ 12 | new winston.transports.Console({ 13 | level: process.env.NODE_ENV === 'production' ? 'error' : 'debug', 14 | }), 15 | new winston.transports.File({ filename: 'error.log', level: 'error' }), 16 | new winston.transports.File({ filename: 'general.log' }), 17 | ], 18 | format: combine(timestamp({ format: 'DD/MM/YYYY HH:mm:ss' }), logFormat), 19 | }; 20 | 21 | const logger = winston.createLogger(options); 22 | 23 | export default logger; 24 | -------------------------------------------------------------------------------- /src/util/replies.ts: -------------------------------------------------------------------------------- 1 | import { ValueType, ValueBoundary } from '../types/sketch' 2 | 3 | export const invalidValues = (type: ValueType, prop: string, allowed: ValueBoundary) => 4 | `Invalid value for '${prop}', this option ${ 5 | type === 'allowed' ? 'accepts' : 'must be between these numbers' 6 | }: ${allowed.join(', ')}`; 7 | 8 | export default { 9 | standard: 'Here is your glitched image :)', 10 | invalidImage: 'No valid image found in the parent tweet', 11 | defaultConfig: 12 | 'No valid configuration found, using default config.\nFor more information on using custom options, visit https://github.com/glitchartbot/glitch-art-bot-scripts', 13 | invalidSketch: 14 | 'No sketch found with this name, visit https://github.com/glitchartbot/glitch-art-bot-scripts for available scripts', 15 | orphanTweet: 16 | 'No parent tweet found, for more information on the bot usage, visit https://github.com/glitchartbot/glitch-art-bot-scripts', 17 | }; -------------------------------------------------------------------------------- /src/sketch/definitions.ts: -------------------------------------------------------------------------------- 1 | import { SketchConfig } from '../types/sketch'; 2 | 3 | const globalDefault = { 4 | photo: 1, 5 | }; 6 | 7 | export const sketchesConfig: SketchConfig[] = [ 8 | { 9 | name: 'pixelsort', 10 | parameters: ['mode'], 11 | values: { 12 | mode: { 13 | boundaries: [1, 2, 3], 14 | type: 'allowed', 15 | }, 16 | }, 17 | defaultConfig: { 18 | ...globalDefault, 19 | mode: 1, 20 | }, 21 | }, 22 | { 23 | name: 'pixeldrift', 24 | parameters: ['channel', 'dir', 'amount'], 25 | values: { 26 | channel: { 27 | boundaries: [1, 2, 3], 28 | type: 'allowed', 29 | }, 30 | dir: { 31 | boundaries: [1, 2], 32 | type: 'allowed', 33 | }, 34 | amount: { 35 | boundaries: [0, 100], 36 | type: 'range', 37 | }, 38 | }, 39 | defaultConfig: { 40 | ...globalDefault, 41 | channel: 1, 42 | dir: 1, 43 | amount: 70, 44 | }, 45 | }, 46 | ]; 47 | -------------------------------------------------------------------------------- /test/replies.test.ts: -------------------------------------------------------------------------------- 1 | import { invalidValues } from '../src/util/replies'; 2 | 3 | test('retorna o motivo certo de erro', () => { 4 | const expected1 = `Invalid value for 'mode', this option accepts: 1, 2, 3`; 5 | const expected2 = `Invalid value for 'threshold', this option must be between these numbers: 0, 100`; 6 | const expected3 = `Invalid value for 'some', this option must be between these numbers: 0, 100`; 7 | 8 | const dummyObj = { 9 | //Return obj from `isValidValues` 10 | status: 'error', 11 | type: 'range', 12 | prop: 'some', 13 | //SketchConfig alike 14 | values: { 15 | some: { 16 | boundaries: [0, 100], 17 | type: 'range', 18 | }, 19 | }, 20 | }; 21 | 22 | expect(invalidValues('allowed', 'mode', [1, 2, 3])).toBe(expected1); 23 | expect(invalidValues('range', 'threshold', [0, 100])).toBe(expected2); 24 | expect( 25 | invalidValues( 26 | dummyObj.type as 'range', 27 | dummyObj.prop, 28 | dummyObj.values[dummyObj.prop].boundaries 29 | ) 30 | ).toBe(expected3); 31 | }); 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 João Friaça 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-en-us.md: -------------------------------------------------------------------------------- 1 | # glitch-art-bot-ts 2 | 3 | Twitter bot (@GlitchArtBot) that applies glitch art effects in images. 4 | 5 | ## How it works 6 | 7 | The bot uses its credentials, connects to Twitter's stream and listens to whenever a user mentions its username, then the bot verifies if the "parent" tweet of which it was mentioned has any valid image (files classified as `photo` by Twitter), if it has, the bot downloads the image, creates a child process and executes a command that applies the effects on the image, then it replies to the user that mentioned the bot with the edited image. 8 | 9 | ## How to use 10 | 11 | There's a repository just for that, how amazing! [Click here](https://github.com/glitchartbot/glitch-art-bot-scripts) for a detailed explanation on how to use the bot and customize it! :) 12 | 13 | ## Available scripts 14 | 15 | \*_Even though most of (if not all) the scripts are not of my authorship, they had to be adjusted to work properly with the bot. To see the scripts used by the bot and how they work, [click here](https://github.com/glitchartbot/glitch-art-bot-scripts)._ 16 | 17 | - Pixel Sort by [Kim Asendorf](https://github.com/kimasendorf) 18 | - Pixel Drift by [João Friaça](https://github.com/friaca) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **_To read this documentation in english, [click here](./README-en-us.md)_** 2 | 3 | # glitch-art-bot-ts 4 | 5 | Bot do Twitter (@GlitchArtBot) que aplica efeitos de glitch art em imagens. 6 | 7 | ## Como funciona 8 | 9 | O bot usa as credenciais dele, conecta na stream do Twitter e fica "ouvindo" quando algum usuário menciona o nome de usuário dele, então ele verifica se o tweet "pai" do qual ele foi mencionado tem uma imagem válida (arquivos classificados como `photo` pelo Twitter), se tiver, o bot baixa a imagem, cria um processo filho e executa um comando que aplica os efeitos na imagem, então responde o usuário que mencionou o bot com a imagem editada. 10 | 11 | ## Como usar 12 | 13 | Tem um repositório só pra isso, olha que incrível! [Clique aqui](https://github.com/glitchartbot/glitch-art-bot-scripts) pra uma explicação detalhada de como usar e personalizar o bot! :) 14 | 15 | ## Scripts disponíveis 16 | 17 | \*_Por mais que a maioria, (senão todos) os scripts não sejam de autoria minha, eles tiveram que ser ajustados pra funcionarem corretamente com o bot. Pra ver os scripts usados pelo bot e como usá-los, [clique aqui](https://github.com/glitchartbot/glitch-art-bot-scripts)._ 18 | 19 | - Pixel Sort por [Kim Asendorf](https://github.com/kimasendorf) 20 | - Pixel Drift por [João Friaça](https://github.com/friaca) 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glitch-art-bot-ts", 3 | "version": "1.0.0", 4 | "description": "Typed implementation of GlitchArtBot", 5 | "main": "index.js", 6 | "dependencies": { 7 | "dotenv": "^8.2.0", 8 | "got": "^10.6.0", 9 | "twit": "^2.2.11", 10 | "winston": "^3.2.1" 11 | }, 12 | "devDependencies": { 13 | "@babel/core": "^7.8.7", 14 | "@babel/preset-env": "^7.8.7", 15 | "@babel/preset-typescript": "^7.8.3", 16 | "@types/jest": "^25.1.4", 17 | "@types/node": "^13.7.7", 18 | "@types/winston": "^2.4.4", 19 | "babel-jest": "^25.1.0", 20 | "jest": "^25.1.0", 21 | "prettier": "2.0.5", 22 | "typescript": "^3.8.3" 23 | }, 24 | "scripts": { 25 | "build": "tsc", 26 | "dev": "tsc && node build/index.js", 27 | "test": "jest", 28 | "prettier": "prettier --write --config ./.prettierrc \"src/**/*.ts\"" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/friaca/glitch-art-bot-ts.git" 33 | }, 34 | "keywords": [ 35 | "glitch-art", 36 | "bot", 37 | "art", 38 | "generative-art", 39 | "twitter" 40 | ], 41 | "author": "João Friaça", 42 | "license": "ISC", 43 | "bugs": { 44 | "url": "https://github.com/friaca/glitch-art-bot-ts/issues" 45 | }, 46 | "homepage": "https://github.com/friaca/glitch-art-bot-ts#readme" 47 | } 48 | -------------------------------------------------------------------------------- /src/sketch/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import { sketchesConfig } from './definitions'; 4 | import { SketchName, SketchConfig } from '../types/sketch'; 5 | import { Configuration, File } from '../types/utils'; 6 | import { stringifyConfig } from '../util/common'; 7 | 8 | export enum PSketchesEnum { 9 | pixelsort = 'pixelsort', 10 | pixeldrift = 'pixeldrift', 11 | } 12 | 13 | const path = process.env.P3_PATH; 14 | const sketchBase = process.env.P3_SKETCH_BASE as string; 15 | 16 | export const getAvailableSketchNames = (): string[] => sketchesConfig.map(sketch => sketch.name); 17 | 18 | export const getSketchConfig = (sketchName: SketchName): SketchConfig => 19 | sketchesConfig.find(sketch => sketch.name === sketchName) as SketchConfig; 20 | 21 | export const getAssetsPath = (sketchName: SketchName) => join(sketchBase, sketchName, 'assets'); 22 | 23 | export const getOutputPath = (sketchName: SketchName) => join(sketchBase, sketchName, 'output'); 24 | 25 | export function getProcessingCmd(sketchName: SketchName, configuration?: Configuration, file?: File): string { 26 | const sketchConfig = getSketchConfig(sketchName); 27 | const purePath = `${path} --sketch=${join(sketchBase, sketchConfig.name)} --run`; 28 | const args = configuration ? stringifyConfig(configuration, sketchConfig.parameters) : ''; 29 | const fileInfo = file ? `filename=${file.name} format=${file.extension}` : ''; 30 | 31 | return [purePath, args, fileInfo].join(' '); 32 | } 33 | -------------------------------------------------------------------------------- /test/sketch.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { 3 | getProcessingCmd, 4 | getAssetsPath, 5 | getAvailableSketchNames, 6 | getSketchConfig, 7 | } from '../src/sketch'; 8 | 9 | test('retorna o comando certo pra executar o script', () => { 10 | const regexPurePath = /^.+-(\d.\d.\d).+\.exe --sketch=.+(\\|\/)(\w+) --run$/gm; 11 | const regexPathWithArgs = /^.+-(\d.\d.\d).+\.exe --sketch=.+(\\|\/)(\w+) --run( \w+=\d| \w+=\d ){0,}$/gm; 12 | const cmdPure = getProcessingCmd('pixelsort'); 13 | const cmdWithArgs = getProcessingCmd('pixelsort', { mode: 1, photo: 2 }); 14 | const matches = [cmdPure.match(regexPurePath), cmdWithArgs.match(regexPathWithArgs)]; 15 | const allMatches = matches.filter(el => el).length === matches.length; 16 | 17 | const literalCmd = `${process.env.P3_PATH} --sketch=${path.join(process.env.P3_SKETCH_BASE, 'pixelsort')} --run mode=1 filename=abc format=.png` 18 | const cmdToMatchLiteral = getProcessingCmd('pixelsort', { mode: 1, photo: 2 }, { name: 'abc', extension: '.png'}); 19 | 20 | expect(allMatches).toBe(true); 21 | expect(cmdToMatchLiteral).toBe(literalCmd); 22 | }); 23 | 24 | test('retorna o caminho da pasta de recursos (fontes)', () => { 25 | const regex = /pixelsort(\\|\/)assets$/gm; 26 | const pathAssets = getAssetsPath('pixelsort'); 27 | const matches = pathAssets.match(regex); 28 | 29 | expect(matches).toBeTruthy(); 30 | }); 31 | 32 | test('retorna o nome dos sketches disponíveis', () => { 33 | const expected = ['pixelsort', 'pixeldrift']; 34 | expect(getAvailableSketchNames().sort()).toEqual(expected.sort()); 35 | }); 36 | 37 | test('retorna a configuração do sketch', () => { 38 | const expected = { 39 | name: 'pixelsort', 40 | parameters: ['mode'], 41 | values: { 42 | mode: { 43 | boundaries: [1, 2, 3], 44 | type: 'allowed', 45 | }, 46 | }, 47 | defaultConfig: { 48 | photo: 1, 49 | mode: 1, 50 | }, 51 | }; 52 | 53 | expect(getSketchConfig('pixelsort')).toStrictEqual(expected); 54 | }); 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Build 107 | build/ -------------------------------------------------------------------------------- /src/bot.ts: -------------------------------------------------------------------------------- 1 | const twit = require('twit'); 2 | 3 | import { readFileSync } from 'fs'; 4 | import { keys } from './config'; 5 | import * as utils from './util/common'; 6 | 7 | import { Response } from './types/twit'; 8 | import { Params } from './types/api'; 9 | import { Tweet } from './types/twitter/tweet'; 10 | import { UploadedMedia } from './types/twitter/media'; 11 | import { SketchName } from './types/sketch'; 12 | import { ExtendedError, File } from './types/utils'; 13 | 14 | const twitter = new twit(keys); 15 | export const ID = '1232403291151196160'; 16 | 17 | function uploadTweet(params: Params): Promise { 18 | return twitter 19 | .post('statuses/update', params) 20 | .then((res: Response) => res.data) 21 | .catch((error: Error) => { 22 | throw error; 23 | }); 24 | } 25 | 26 | function uploadImage(sketch: SketchName, file: File): Promise { 27 | const filePath = utils.getOuputPath(sketch, file); 28 | const b64 = readFileSync(filePath, { encoding: 'base64' }); 29 | const params: Params = { media_data: b64 }; 30 | 31 | return twitter 32 | .post('media/upload', params) 33 | .then((res: Response) => res.data) 34 | .catch((error: ExtendedError) => { 35 | throw error; 36 | }); 37 | } 38 | 39 | export function getTweetById(tweetId: string): Promise { 40 | const params: Params = { id: tweetId, tweet_mode: 'extended' }; 41 | 42 | return twitter 43 | .get('statuses/show', params) 44 | .then((res: Response) => res.data) 45 | .catch((error: ExtendedError) => { 46 | error.id = tweetId; 47 | throw error; 48 | }); 49 | } 50 | 51 | export function listenQuery(query: string | string[], callback: Function) { 52 | const stream = twitter.stream('statuses/filter', { track: query }); 53 | stream.on('tweet', callback); 54 | } 55 | 56 | export function replyTweet(tweetId: string): Promise; 57 | export function replyTweet(tweetId: string, status: string): Promise; 58 | export function replyTweet( 59 | tweetId: string, 60 | status?: string, 61 | sketch?: SketchName, 62 | file?: File 63 | ): Promise; 64 | 65 | export async function replyTweet( 66 | tweetId: string, 67 | status?: string, 68 | sketch?: SketchName, 69 | file?: File 70 | ): Promise { 71 | try { 72 | const tweet = await getTweetById(tweetId); 73 | const screenName = tweet.user.screen_name; 74 | let uploaded: UploadedMedia; 75 | let params: Params = { 76 | in_reply_to_status_id: tweet.id_str, 77 | status: `@${screenName}`, 78 | }; 79 | 80 | if (sketch && file) { 81 | uploaded = await uploadImage(sketch, file); 82 | params.media_ids = [uploaded.media_id_string]; 83 | } 84 | 85 | if (status) { 86 | params.status += ` ${status}`; 87 | } 88 | 89 | return await uploadTweet(params); 90 | } catch (error) { 91 | throw error; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './util/secrets'; 2 | 3 | import * as bot from './bot'; 4 | import * as utils from './util/common'; 5 | import replies, { invalidValues } from './util/replies'; 6 | import { getProcessingCmd, getSketchConfig } from './sketch'; 7 | 8 | import { exec } from 'child_process'; 9 | import { promisify } from 'util'; 10 | import { promises } from 'fs'; 11 | 12 | import { Log, Configuration, File } from './types/utils'; 13 | import { Tweet } from './types/twitter/tweet'; 14 | import { SketchConfig, SketchName } from './types/sketch'; 15 | 16 | const { access, mkdir } = promises; 17 | const execAsync = promisify(exec); 18 | 19 | async function onTweet(tweet: Tweet) { 20 | if (tweet.in_reply_to_user_id_str == bot.ID) return; 21 | 22 | let baseLog = { id: tweet.id_str }; 23 | 24 | try { 25 | const tweetId = tweet.id_str; 26 | const parentId = utils.getParentTweetId(tweet); 27 | 28 | if (!parentId) return; 29 | 30 | utils.log({ 31 | level: 'info', 32 | message: 'Tweet válido recebido na stream', 33 | ...baseLog, 34 | }); 35 | 36 | const parentTweet = await bot.getTweetById(parentId as string); 37 | 38 | if (!utils.hasValidImage(parentTweet)) return; 39 | 40 | const tweetText = utils.removeMentions(tweet.full_text ?? (tweet.text as string)); 41 | const [sketchName, customOptions] = utils.resolveText(tweetText); 42 | 43 | let chosenSketch: SketchConfig; 44 | let replyText: string; 45 | let config: Configuration; 46 | 47 | if (utils.isValidSketch(sketchName)) { 48 | chosenSketch = getSketchConfig(sketchName as SketchName); 49 | } else { 50 | chosenSketch = getSketchConfig('pixelsort'); 51 | } 52 | 53 | if (utils.isValidConfig(customOptions)) { 54 | config = utils.mergeOptions(chosenSketch.defaultConfig, utils.parseConfig(utils.prepareOptions(customOptions))); 55 | replyText = replies.standard; 56 | } else { 57 | replyText = replies.defaultConfig; 58 | config = chosenSketch.defaultConfig; 59 | } 60 | 61 | const values = utils.isValidValues(config, chosenSketch); 62 | 63 | if (values.status === 'error') { 64 | return bot.replyTweet( 65 | tweetId, 66 | invalidValues(values.type, values.prop, chosenSketch.values[values.prop].boundaries) 67 | ); 68 | } 69 | 70 | //Baixa a imagem do tweet 71 | const imageUrl = utils.getImageUrl(parentTweet, true, config.photo); 72 | const extension = utils.getFileExtension(parentTweet, config.photo); 73 | const file: File = { name: tweetId, extension }; 74 | await utils.downloadImage(imageUrl, chosenSketch.name, file); 75 | 76 | //Executa o comando que edita a imagem 77 | const cmd = getProcessingCmd(chosenSketch.name, config, file); 78 | const { stderr } = await execAsync(cmd); 79 | 80 | if (stderr) { 81 | utils.log({ 82 | level: 'error', 83 | message: 'Não foi possível editar a imagem', 84 | ...baseLog, 85 | }); 86 | } else { 87 | utils.log({ 88 | level: 'info', 89 | message: 'A imagem foi editada', 90 | ...baseLog, 91 | }); 92 | } 93 | 94 | //Responde o tweet que mencionou ele 95 | const reply = await bot.replyTweet(tweetId, replyText, chosenSketch.name, file); 96 | 97 | if (reply.id_str) { 98 | utils.log({ 99 | level: 'info', 100 | message: 'Respondido o tweet com imagem editada', 101 | ...baseLog, 102 | }); 103 | 104 | utils.deleteFile(chosenSketch.name, file); 105 | } else { 106 | utils.log({ 107 | level: 'error', 108 | message: 'O tweet não foi respondido com a imagem editada', 109 | ...baseLog, 110 | }); 111 | } 112 | 113 | return; 114 | } catch (error) { 115 | const log: Log = { 116 | level: 'error', 117 | message: error.message, 118 | ...baseLog, 119 | }; 120 | 121 | utils.log(log, error); 122 | throw error; 123 | } 124 | } 125 | 126 | async function main() { 127 | console.log('Starting Glitch Art Bot...'); 128 | 129 | try { 130 | await access('./logs'); 131 | } catch (error) { 132 | mkdir('./logs', { recursive: true }); 133 | } 134 | 135 | bot.listenQuery('@GlitchArtBot', onTweet); 136 | console.log('The bot started succesfully!'); 137 | } 138 | 139 | main(); -------------------------------------------------------------------------------- /src/types/twitter/tweet.d.ts: -------------------------------------------------------------------------------- 1 | export interface GeoCoordinates { 2 | coordinates: [number, number] | [number, number][] | [number, number][][]; 3 | type: string; 4 | } 5 | 6 | export interface Geo { 7 | country: string; 8 | country_code: string; 9 | locality: string; 10 | region: string; 11 | sub_region: string; 12 | full_name: string; 13 | geo: GeoCoordinates; 14 | } 15 | 16 | export interface UserEntities { 17 | description?: { urls?: string[] | undefined | Url[] }; 18 | url?: { urls: Url[] }; 19 | } 20 | 21 | export interface User { 22 | id: number; 23 | id_str: string; 24 | name: string; 25 | screen_name: string; 26 | location?: string; 27 | derived?: Geo[]; 28 | url?: string; 29 | description?: string; 30 | entities?: UserEntities; 31 | protected: boolean; 32 | verified: boolean; 33 | followers_count: number; 34 | friends_count: number; 35 | listed_count: number; 36 | favourites_count: number; 37 | statuses_count: number; 38 | created_at: string; 39 | profile_banner_url?: string; 40 | profile_image_url_https?: string; 41 | default_profile: boolean; 42 | default_profile_image: boolean; 43 | withheld_in_countries?: string[]; 44 | withheld_scope?: string; 45 | // Propriedades deprecadas, 46 | // inserindo pra ser complacente com o tweet 47 | utc_offset: string | number | null; 48 | time_zone: string | number | null; 49 | geo_enabled: unknown; 50 | lang: string; 51 | contributors_enabled: boolean; 52 | is_translator: boolean; 53 | is_translation_enabled: boolean; 54 | profile_background_color: string | null; 55 | profile_background_image_url: string | null; 56 | profile_background_image_url_https: string | null; 57 | profile_background_tile: boolean; 58 | profile_image_url: string; 59 | profile_link_color: string; 60 | profile_sidebar_border_color: string; 61 | profile_sidebar_fill_color: string; 62 | profile_text_color: string; 63 | profile_use_background_image: boolean; 64 | has_extended_profile: boolean; 65 | following: boolean; 66 | follow_request_sent: boolean; 67 | notifications: boolean; 68 | translator_type: string | null; 69 | } 70 | 71 | export interface Place { 72 | id: string; 73 | url: string; 74 | place_type: string; 75 | name: string; 76 | full_name: string; 77 | country_code: string; 78 | country: string; 79 | bounding_box: GeoCoordinates; 80 | attributes?: object; 81 | } 82 | 83 | export interface Hashtag { 84 | indices: number[]; 85 | text: string; 86 | } 87 | 88 | export interface Sizes { 89 | thumb: Size; 90 | large: Size; 91 | medium: Size; 92 | small: Size; 93 | } 94 | 95 | export interface Size { 96 | w: number; 97 | h: number; 98 | resize: 'crop' | 'fit'; 99 | } 100 | 101 | export interface Media { 102 | display_url: string; 103 | expanded_url: string; 104 | id: number; 105 | id_str: string; 106 | indices: number[]; 107 | media_url: string; 108 | media_url_https: string; 109 | sizes: Sizes; 110 | source_status_id?: number; 111 | source_status_id_str?: string; 112 | type: 'photo' | 'video' | 'animated_gif'; 113 | url: string; 114 | } 115 | 116 | export interface Unwound { 117 | url: string; 118 | status: number; 119 | title: string; 120 | description: string; 121 | } 122 | 123 | export interface Url { 124 | display_url: string; 125 | expanded_url: string; 126 | indices: number[]; 127 | url: string; 128 | unwound?: Unwound; 129 | } 130 | 131 | export interface UserMention { 132 | id: number; 133 | id_str: string; 134 | indices: number[]; 135 | name: string; 136 | screen_name: string; 137 | } 138 | 139 | export interface TSymbol { 140 | indices: number[]; 141 | text: string; 142 | } 143 | 144 | export interface PollOption { 145 | position: number; 146 | text: string; 147 | } 148 | 149 | export interface Poll { 150 | options: PollOption[]; 151 | end_datetime: string; 152 | duration_minutes: string; 153 | } 154 | 155 | export interface Entities { 156 | hashtags: Hashtag[]; 157 | media?: Media[]; 158 | urls: Url[]; 159 | user_mentions: UserMention[]; 160 | symbols: TSymbol[]; 161 | polls?: Poll[]; 162 | } 163 | 164 | export interface MatchingRule { 165 | tag: string; 166 | id: number; 167 | id_str: string; 168 | } 169 | 170 | export interface ExtendedEntities { 171 | media: Media[]; 172 | } 173 | 174 | export interface Tweet { 175 | created_at: string; 176 | id: number; 177 | id_str: string; 178 | text?: string; 179 | full_text?: string; 180 | source: string; 181 | truncated: boolean; 182 | display_text_range?: number[]; 183 | in_reply_to_status_id: number | null; 184 | in_reply_to_status_id_str: string | null; 185 | in_reply_to_user_id: number | null; 186 | in_reply_to_user_id_str: string | null; 187 | in_reply_to_screen_name: string; 188 | user: User; 189 | coordinates?: GeoCoordinates; 190 | place?: Place; 191 | quoted_status_id?: number; 192 | quoted_status_id_str?: string; 193 | is_quote_status: boolean; 194 | quoted_status?: Tweet; 195 | retweeted_status?: Tweet; 196 | quote_count?: number; 197 | reply_count?: number; 198 | retweet_count: number; 199 | favorite_count?: number; 200 | entities: Entities; 201 | extended_entities?: ExtendedEntities; 202 | favorited: boolean; 203 | retweeted: boolean; 204 | possibly_sensitive?: boolean; 205 | filter_level?: 'none' | 'low' | 'medium' | 'high'; 206 | lang: string; 207 | matching_rules?: MatchingRule[]; 208 | possibly_sensitive_appealable?: boolean; 209 | // Propriedades deprecadas, 210 | // inserindo pra ser complacente com o tweet 211 | geo: null; 212 | contributors: null; 213 | } 214 | -------------------------------------------------------------------------------- /src/util/common.ts: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | import { createWriteStream, unlink, promises } from 'fs'; 3 | import { join } from 'path'; 4 | import { pipeline } from 'stream'; 5 | import { getAvailableSketchNames, getAssetsPath, getOutputPath } from '../sketch'; 6 | import { promisify } from 'util'; 7 | import logger from './logger'; 8 | 9 | import { SketchName, SketchConfig, ValueBoundary, ValueType } from '../types/sketch'; 10 | import { Tweet } from '../types/twitter/tweet'; 11 | import { File, Log, Configuration, ValueValidation } from '../types/utils'; 12 | 13 | const { writeFile } = promises; 14 | const pipelineAsync = promisify(pipeline); 15 | 16 | export const getParentTweetId = (tweet: Tweet) => tweet.in_reply_to_status_id_str; 17 | 18 | export const hasValidImage = (tweet: Tweet) => 19 | Boolean( 20 | tweet.entities.media && tweet.entities.media.filter(media => media.type === 'photo').length 21 | ); 22 | 23 | export const isValidSketch = (sketchName: string): boolean => 24 | getAvailableSketchNames().find(sketch => sketch === sketchName) !== undefined; 25 | 26 | export const isValidConfig = (text: string): boolean => 27 | Boolean(text.trim().match(/^(\w+=\d+( |)+){1,}$/gm)); 28 | 29 | export const getFilePath = (sketch: SketchName, file: File) => 30 | join(getAssetsPath(sketch), `${file.name}${file.extension}`); 31 | 32 | export const getOuputPath = (sketch: SketchName, file: File) => 33 | join(getOutputPath(sketch), `${file.name}${file.extension}`); 34 | 35 | export const removeMentions = (text: string): string => 36 | text 37 | .split(' ') 38 | .filter(el => !el.startsWith('@') && Boolean(el)) 39 | .join(' '); 40 | 41 | export function getImageUrl(tweet: Tweet, withSize: boolean, index: number = 1): string { 42 | const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max); 43 | 44 | const photos = tweet.extended_entities!.media!.filter(media => media.type === 'photo'); 45 | const finalIndex = clamp(index, 1, photos.length) - 1; 46 | 47 | return withSize 48 | ? photos![finalIndex].media_url.concat('?name=large') 49 | : photos![finalIndex].media_url; 50 | } 51 | 52 | export const getFileExtension = (tweet: Tweet, index: number = 1) => 53 | getImageUrl(tweet, false, index).match(/\.[0-9a-z]+$/i)![0]; 54 | 55 | export const getTweetUrl = (tweet: Tweet) => 56 | `https://twitter.com/${tweet.user.screen_name}/status/${tweet.id_str}`; 57 | 58 | export async function downloadImage(uri: string, sketch: SketchName, file: File) { 59 | try { 60 | return await pipelineAsync(got.stream(uri), createWriteStream(getFilePath(sketch, file))); 61 | } catch (error) { 62 | throw error; 63 | } 64 | } 65 | 66 | export const stringifyConfig = (config: Configuration, whitelist: string[]) => 67 | Object.entries(config) 68 | .filter(([key]) => whitelist.includes(key)) 69 | .map(([key, value]) => `${key}=${value}`) 70 | .join(' ') 71 | 72 | export function resolveText(text: string): string[] { 73 | const [first, ...rest] = text.split(' '); 74 | 75 | // como eu queria que tivesse pattern matching no javascript... 76 | if (isValidSketch(first)) return [first, rest.join(' ')]; 77 | else if (isValidConfig(first)) return ['', text]; 78 | else return ['', '']; 79 | } 80 | 81 | export const parseConfig = (configText: string) => 82 | configText 83 | .split(' ') 84 | .map(each => each.split('=')) 85 | .reduce((acc, [key, value]) => ({...acc, ...{[key]: Number(value)}}), {}) 86 | 87 | const isAllowedValue = (allowed: ValueBoundary, value: number, type: ValueType) => 88 | type === 'allowed' ? 89 | allowed.includes(value) : 90 | value >= allowed[0] && value <= allowed[1]; 91 | 92 | export function isValidValues(configObj: Configuration, sketchConfig: SketchConfig) { 93 | const { parameters } = sketchConfig; 94 | const keys = Object.keys(configObj).filter(key => !['photo', '_'].includes(key)); 95 | let valid: ValueValidation = { 96 | status: 'success', 97 | prop: '', 98 | type: 'allowed', 99 | }; 100 | 101 | for (const key of keys) { 102 | if (!parameters.includes(key)) continue; 103 | 104 | if (sketchConfig.values[key].type === 'allowed') { 105 | if ( 106 | !isAllowedValue( 107 | sketchConfig.values[key].boundaries, 108 | configObj[key], 109 | sketchConfig.values[key].type 110 | ) 111 | ) { 112 | valid = { status: 'error', prop: key, type: 'allowed' }; 113 | break; 114 | } 115 | } else { 116 | if ( 117 | !isAllowedValue( 118 | sketchConfig.values[key].boundaries, 119 | configObj[key], 120 | sketchConfig.values[key].type 121 | ) 122 | ) { 123 | valid = { status: 'error', prop: key, type: 'range' }; 124 | break; 125 | } 126 | } 127 | } 128 | 129 | return valid; 130 | } 131 | 132 | export function deleteFile(sketch: SketchName, file: File): void { 133 | const assets = join(getAssetsPath(sketch), `${file.name}${file.extension}`); 134 | const output = join(getOutputPath(sketch), `${file.name}${file.extension}`); 135 | 136 | unlink(assets, () => { 137 | unlink(output, () => {}); 138 | }); 139 | } 140 | 141 | export const mergeOptions = (defaultOptions: Configuration, customOptions: Configuration) => ({ 142 | ...defaultOptions, 143 | ...customOptions, 144 | }); 145 | 146 | export const prepareOptions = (customOptions: string): string => 147 | customOptions 148 | .trim() 149 | .replace(/\r?\n|\r/g, ' ') 150 | .split(' ') 151 | .filter(Boolean) 152 | .join(' '); 153 | 154 | export function log(logEntry: Log): void; 155 | export function log(logEntry: Log, error: Error): void; 156 | export function log(logEntry: Log, error: Error, tweet: Tweet): void; 157 | 158 | export function log(logEntry: Log, error?: Error, tweet?: Tweet): void { 159 | logger.log(logEntry); 160 | 161 | if (error || tweet) { 162 | const json = JSON.stringify({ error, tweet }, null, 2); 163 | const logId = Date.now(); 164 | 165 | writeFile(`logs/${logId}.json`, json); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./build", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | "strictNullChecks": true, /* Enable strict null checks. */ 29 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | 63 | /* Advanced Options */ 64 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 65 | "noEmitOnError": true /* Do not emit outputs if any errors were reported. */ 66 | }, 67 | "include": [ 68 | "src/*.ts", 69 | "src/util/*.ts", 70 | "src/sketch/*.ts", 71 | ], 72 | "exclude": [ 73 | "**/node_modules/**", 74 | "build/**/*", 75 | ], 76 | "files": [ 77 | "src/types/twit-module.d.ts", 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getFileExtension, 3 | hasValidImage, 4 | getParentTweetId, 5 | getFilePath, 6 | getTweetUrl, 7 | getImageUrl, 8 | downloadImage, 9 | isValidSketch, 10 | isValidConfig, 11 | prepareOptions, 12 | mergeOptions, 13 | removeMentions, 14 | resolveText, 15 | isValidValues 16 | } from '../src/util/common'; 17 | 18 | import { existsSync, unlinkSync } from 'fs'; 19 | 20 | import { File, Configuration } from '../src/types/utils'; 21 | 22 | import * as tweets from './mocks/tweets'; 23 | import { getSketchConfig, PSketchesEnum } from '../src/sketch'; 24 | import { SketchConfig } from '../src/types/sketch'; 25 | 26 | test('extrai a extensão correta das imagens', () => { 27 | expect(getFileExtension(tweets.mediaExtended)).toBe('.jpg'); 28 | expect(getFileExtension(tweets.mediaNotExtended)).toBe('.png'); 29 | }); 30 | 31 | test('retorna a existência de imagem válida no tweet', () => { 32 | expect(hasValidImage(tweets.noMediaExtended)).toBe(false); 33 | expect(hasValidImage(tweets.noMediaNotExtended)).toBe(false); 34 | expect(hasValidImage(tweets.mediaExtended)).toBe(true); 35 | expect(hasValidImage(tweets.mediaNotExtended)).toBe(true); 36 | }); 37 | 38 | test('retorna o "tweet pai"', () => { 39 | expect(getParentTweetId(tweets.sonTweet)).toBe('1235375942924632064'); 40 | expect(getParentTweetId(tweets.orphanTweet)).toBeNull(); 41 | }); 42 | 43 | test('retorna o caminho do arquivo desejado', () => { 44 | const imagem1: File = { 45 | name: 'imagem', 46 | extension: '.jpg', 47 | }; 48 | 49 | //TODO: Corrigir caminho de teste 50 | expect(getFilePath('pixelsort', imagem1)).toBe( 51 | 'C:\\Processing\\sketches-p3\\pixelsort\\assets\\imagem.jpg' 52 | ); 53 | }); 54 | 55 | test('retorna a url do tweet', () => { 56 | expect(getTweetUrl(tweets.sonTweet)).toBe( 57 | 'https://twitter.com/phriaca/status/1235377172883402753' 58 | ); 59 | expect(getTweetUrl(tweets.orphanTweet)).toBe( 60 | 'https://twitter.com/tthisolddog/status/1235375942924632064' 61 | ); 62 | expect(getTweetUrl(tweets.mediaExtended)).toBe( 63 | 'https://twitter.com/matheusmosa/status/1235349280120066049' 64 | ); 65 | expect(getTweetUrl(tweets.noMediaNotExtended)).toBe( 66 | 'https://twitter.com/phriaca/status/1232476926926708737' 67 | ); 68 | }); 69 | 70 | test('retorna a url da imagem', () => { 71 | expect(getImageUrl(tweets.mediaExtended, false)).toBe( 72 | 'http://pbs.twimg.com/media/ESTXZQ7XcAAVD3t.jpg' 73 | ); 74 | expect(getImageUrl(tweets.mediaExtended, true)).toBe( 75 | 'http://pbs.twimg.com/media/ESTXZQ7XcAAVD3t.jpg?name=large' 76 | ); 77 | expect(getImageUrl(tweets.mediaNotExtended, false)).toBe( 78 | 'http://pbs.twimg.com/media/ERwMESlWkAconTy.png' 79 | ); 80 | expect(getImageUrl(tweets.mediaNotExtended, true)).toBe( 81 | 'http://pbs.twimg.com/media/ERwMESlWkAconTy.png?name=large' 82 | ); 83 | expect(getImageUrl(tweets.multipleMediaExtended, false)).toBe( 84 | 'http://pbs.twimg.com/media/ESc6pLPWsAEC8Ln.jpg' 85 | ); 86 | expect(getImageUrl(tweets.multipleMediaExtended, true)).toBe( 87 | 'http://pbs.twimg.com/media/ESc6pLPWsAEC8Ln.jpg?name=large' 88 | ); 89 | expect(getImageUrl(tweets.multipleMediaExtended, true)).toBe( 90 | 'http://pbs.twimg.com/media/ESc6pLPWsAEC8Ln.jpg?name=large' 91 | ); 92 | expect(getImageUrl(tweets.multipleMediaExtended, true, 2)).toBe( 93 | 'http://pbs.twimg.com/media/ESc6pllWsAEirIo.jpg?name=large' 94 | ); 95 | expect(getImageUrl(tweets.multipleMediaExtended, true, 1)).toBe( 96 | 'http://pbs.twimg.com/media/ESc6pLPWsAEC8Ln.jpg?name=large' 97 | ); 98 | expect(getImageUrl(tweets.multipleMediaNotExtended, false, 1)).toBe( 99 | 'http://pbs.twimg.com/media/EWAXgzNWkAcpjvs.jpg' 100 | ); 101 | expect(getImageUrl(tweets.multipleMediaNotExtended, false, -1)).toBe( 102 | 'http://pbs.twimg.com/media/EWAXgzNWkAcpjvs.jpg' 103 | ); 104 | }); 105 | 106 | test('baixa as imagens', async () => { 107 | const infoImagem: File = { 108 | name: 'placeholder', 109 | extension: '.png', 110 | }; 111 | 112 | await downloadImage('https://via.placeholder.com/1280', 'pixelsort', infoImagem); 113 | expect(existsSync(getFilePath('pixelsort', infoImagem))).toBe(true); 114 | unlinkSync(getFilePath('pixelsort', infoImagem)); 115 | }); 116 | 117 | test('valida se o sketch escolhido é válido', () => { 118 | const validInputs = ['pixelsort']; 119 | const validResults = validInputs.filter(isValidSketch); 120 | 121 | const invalidInputs = ['pixelSort', 'pixel sort', 'pixel-sort']; 122 | const invalidResults = invalidInputs.filter(isValidSketch); 123 | 124 | expect(validResults.length).toBe(validInputs.length); 125 | expect(invalidResults.length).toBe(0); 126 | }); 127 | 128 | test('valida se o texto do tweet é válido para configuração', () => { 129 | const validInputs = [ 130 | ' mode=2 photo=4', 131 | ' abc=1 def=5', 132 | 'a=3', 133 | ' a=3 b=5', 134 | ' mode=2 photo=4', 135 | 'mode=2 photo=4 ', 136 | ]; 137 | const validResults = validInputs.filter(isValidConfig); 138 | 139 | const invalidInputs = [ 140 | 'pixelsort ', 141 | 'pixel-sort --ab', 142 | ' -a=4', 143 | ' a=b', 144 | 'mode= 5', 145 | 'mode =5', 146 | 'mode = 5', 147 | 'a-b=5', 148 | ' a= ab=5 cd=f', 149 | 'ab=6 =t=4', 150 | 'bv=-', 151 | '6', 152 | 'u-u=5', 153 | ]; 154 | const invalidResults = invalidInputs.filter(isValidConfig); 155 | 156 | expect(validResults.length).toBe(validInputs.length); 157 | expect(invalidResults.length).toBe(0); 158 | }); 159 | 160 | test('prepara as opções removendo caracteres inúteis', () => { 161 | const expected = 'mode=2 photo=2'; 162 | 163 | const inputs = [ 164 | 'mode=2 photo=2', 165 | '\nmode=2\nphoto=2', 166 | '\nmode=2 photo=2', 167 | ' mode=2 photo=2', 168 | 'mode=2 photo=2', 169 | 'mode=2 \n photo=2', 170 | 'mode=2\n photo=2', 171 | 'mode=2 photo=2 ', 172 | ]; 173 | 174 | const allValid = inputs.filter(el => prepareOptions(el) === expected).length === inputs.length; 175 | 176 | expect(allValid).toBe(true); 177 | }); 178 | 179 | test('funde as opções com o padrão', () => { 180 | const expected = { 181 | photo: 2, 182 | mode: 1, 183 | }; 184 | const defaultPixelsort = getSketchConfig('pixelsort'); 185 | const input = { photo: 2 }; 186 | 187 | expect(mergeOptions(defaultPixelsort.defaultConfig, input)).toStrictEqual(expected); 188 | }); 189 | 190 | test('remove as menções desnecessárias do texto', () => { 191 | const expected = 'photo=2'; 192 | const input = '@AllAboutMariah @MariahCarey @WORLDMUSICAWARD @GlitchArtBot photo=2'; 193 | 194 | expect(removeMentions(input)).toBe(expected); 195 | }); 196 | 197 | test('ordena corretamente as opções se não tem script', () => { 198 | const expected1 = ['', 'photo=2']; 199 | const input1 = '@GlitchArtBot photo=2'; 200 | const withoutMentions1 = removeMentions(input1); 201 | const output1 = resolveText(withoutMentions1); 202 | 203 | const expected2 = ['pixelsort', 'photo=2']; 204 | const input2 = '@GlitchArtBot pixelsort photo=2'; 205 | const withoutMentions2 = removeMentions(input2); 206 | const output2 = resolveText(withoutMentions2); 207 | 208 | const expected3 = ['pixelsort', 'photo=2 mode=2']; 209 | const input3 = '@GlitchArtBot pixelsort photo=2 mode=2'; 210 | const withoutMentions3 = removeMentions(input3); 211 | const output3 = resolveText(withoutMentions3); 212 | 213 | const expected4 = ['', 'photo=2 mode=2']; 214 | const input4 = '@GlitchArtBot photo=2 mode=2'; 215 | const withoutMentions4 = removeMentions(input4); 216 | const output4 = resolveText(withoutMentions4); 217 | 218 | const expected5 = ['', '']; 219 | const input5 = '@GlitchArtBot '; 220 | const withoutMentions5 = removeMentions(input5); 221 | const output5 = resolveText(withoutMentions5); 222 | 223 | expect(output1[0]).toBe(expected1[0]); 224 | expect(output1[1]).toBe(expected1[1]); 225 | 226 | expect(output2[0]).toBe(expected2[0]); 227 | expect(output2[1]).toBe(expected2[1]); 228 | 229 | expect(output3[0]).toBe(expected3[0]); 230 | expect(output3[1]).toBe(expected3[1]); 231 | 232 | expect(output4[0]).toBe(expected4[0]); 233 | expect(output4[1]).toBe(expected4[1]); 234 | 235 | expect(output5[0]).toBe(expected5[0]); 236 | expect(output5[1]).toBe(expected5[1]); 237 | }); 238 | 239 | test('informa se o valor das opções são válidos', () => { 240 | const dummySketchConfigAllowed: SketchConfig = { 241 | name: PSketchesEnum.pixelsort, 242 | parameters: ['mode'], 243 | values: { 244 | mode: { 245 | boundaries: [1, 2, 3], 246 | type: 'allowed', 247 | }, 248 | }, 249 | defaultConfig: { 250 | photo: 1, 251 | mode: 1, 252 | }, 253 | }; 254 | 255 | const invalidDummyAllowed: Configuration = { 256 | mode: 4, 257 | photo: 3, 258 | }; 259 | 260 | const validDummyAllowed = { ...invalidDummyAllowed, ...{ mode: 1 } }; 261 | 262 | const dummySketchConfigRange: SketchConfig = { 263 | ...dummySketchConfigAllowed, 264 | ...{ values: { mode: { boundaries: [0, 100], type: 'range' } } }, 265 | }; 266 | 267 | const invalidDummyRange: Configuration = { 268 | mode: 200, 269 | photo: 1, 270 | }; 271 | 272 | const validDummyRange2: Configuration = { 273 | mode: 100, 274 | photo: 1, 275 | }; 276 | 277 | const validDummyRange = { ...invalidDummyRange, ...{ mode: 50 } }; 278 | 279 | const error = (prop, type) => ({ status: 'error', prop, type }); 280 | const success = () => ({ status: 'success', prop: '', type: 'allowed' }); 281 | 282 | expect(isValidValues(invalidDummyAllowed, dummySketchConfigAllowed)).toStrictEqual( 283 | error('mode', 'allowed') 284 | ); 285 | expect(isValidValues(validDummyAllowed, dummySketchConfigAllowed)).toStrictEqual(success()); 286 | expect(isValidValues(invalidDummyRange, dummySketchConfigRange)).toStrictEqual( 287 | error('mode', 'range') 288 | ); 289 | expect(isValidValues(validDummyRange, dummySketchConfigRange)).toStrictEqual(success()); 290 | expect(isValidValues(validDummyRange2, dummySketchConfigRange)).toStrictEqual(success()); 291 | }); -------------------------------------------------------------------------------- /test/mocks/tweets.ts: -------------------------------------------------------------------------------- 1 | import { Tweet } from '../../src/types/twitter/tweet' 2 | 3 | const mediaNotExtended: Tweet = { 4 | "created_at": "Thu Feb 27 03:42:43 +0000 2020", 5 | "id": 1232873671104090000, 6 | "id_str": "1232873671104090113", 7 | "text": "https://t.co/EqNEkhzWlO", 8 | "truncated": false, 9 | "entities": { 10 | "hashtags": [], 11 | "symbols": [], 12 | "user_mentions": [], 13 | "urls": [], 14 | "media": [ 15 | { 16 | "id": 1232873665840189400, 17 | "id_str": "1232873665840189447", 18 | "indices": [ 19 | 0, 20 | 23 21 | ], 22 | "media_url": "http://pbs.twimg.com/media/ERwMESlWkAconTy.png", 23 | "media_url_https": "https://pbs.twimg.com/media/ERwMESlWkAconTy.png", 24 | "url": "https://t.co/EqNEkhzWlO", 25 | "display_url": "pic.twitter.com/EqNEkhzWlO", 26 | "expanded_url": "https://twitter.com/ArtistSubject/status/1232873671104090113/photo/1", 27 | "type": "photo", 28 | "sizes": { 29 | "large": { 30 | "w": 500, 31 | "h": 500, 32 | "resize": "fit" 33 | }, 34 | "small": { 35 | "w": 500, 36 | "h": 500, 37 | "resize": "fit" 38 | }, 39 | "medium": { 40 | "w": 500, 41 | "h": 500, 42 | "resize": "fit" 43 | }, 44 | "thumb": { 45 | "w": 150, 46 | "h": 150, 47 | "resize": "crop" 48 | } 49 | } 50 | } 51 | ] 52 | }, 53 | "extended_entities": { 54 | "media": [ 55 | { 56 | "id": 1232873665840189400, 57 | "id_str": "1232873665840189447", 58 | "indices": [ 59 | 0, 60 | 23 61 | ], 62 | "media_url": "http://pbs.twimg.com/media/ERwMESlWkAconTy.png", 63 | "media_url_https": "https://pbs.twimg.com/media/ERwMESlWkAconTy.png", 64 | "url": "https://t.co/EqNEkhzWlO", 65 | "display_url": "pic.twitter.com/EqNEkhzWlO", 66 | "expanded_url": "https://twitter.com/ArtistSubject/status/1232873671104090113/photo/1", 67 | "type": "photo", 68 | "sizes": { 69 | "large": { 70 | "w": 500, 71 | "h": 500, 72 | "resize": "fit" 73 | }, 74 | "small": { 75 | "w": 500, 76 | "h": 500, 77 | "resize": "fit" 78 | }, 79 | "medium": { 80 | "w": 500, 81 | "h": 500, 82 | "resize": "fit" 83 | }, 84 | "thumb": { 85 | "w": 150, 86 | "h": 150, 87 | "resize": "crop" 88 | } 89 | } 90 | } 91 | ] 92 | }, 93 | "source": "Twitter Web App", 94 | "in_reply_to_status_id": null, 95 | "in_reply_to_status_id_str": null, 96 | "in_reply_to_user_id": null, 97 | "in_reply_to_user_id_str": null, 98 | "in_reply_to_screen_name": null, 99 | "user": { 100 | "id": 1232864552976625700, 101 | "id_str": "1232864552976625664", 102 | "name": "ArtistTestSubject", 103 | "screen_name": "ArtistSubject", 104 | "location": "", 105 | "description": "", 106 | "url": null, 107 | "entities": { 108 | "description": { 109 | "urls": [] 110 | } 111 | }, 112 | "protected": false, 113 | "followers_count": 0, 114 | "friends_count": 1, 115 | "listed_count": 0, 116 | "created_at": "Thu Feb 27 03:06:57 +0000 2020", 117 | "favourites_count": 0, 118 | "utc_offset": null, 119 | "time_zone": null, 120 | "geo_enabled": false, 121 | "verified": false, 122 | "statuses_count": 2, 123 | "lang": null, 124 | "contributors_enabled": false, 125 | "is_translator": false, 126 | "is_translation_enabled": false, 127 | "profile_background_color": "F5F8FA", 128 | "profile_background_image_url": null, 129 | "profile_background_image_url_https": null, 130 | "profile_background_tile": false, 131 | "profile_image_url": "http://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png", 132 | "profile_image_url_https": "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png", 133 | "profile_link_color": "1DA1F2", 134 | "profile_sidebar_border_color": "C0DEED", 135 | "profile_sidebar_fill_color": "DDEEF6", 136 | "profile_text_color": "333333", 137 | "profile_use_background_image": true, 138 | "has_extended_profile": false, 139 | "default_profile": true, 140 | "default_profile_image": true, 141 | "following": false, 142 | "follow_request_sent": false, 143 | "notifications": false, 144 | "translator_type": "none" 145 | }, 146 | "geo": null, 147 | "coordinates": null, 148 | "place": null, 149 | "contributors": null, 150 | "is_quote_status": false, 151 | "retweet_count": 0, 152 | "favorite_count": 0, 153 | "favorited": false, 154 | "retweeted": false, 155 | "possibly_sensitive": false, 156 | "possibly_sensitive_appealable": false, 157 | "lang": "und" 158 | } 159 | 160 | const mediaExtended: Tweet = { 161 | "created_at": "Wed Mar 04 23:39:54 +0000 2020", 162 | "id": 1235349280120066049, 163 | "id_str": "1235349280120066049", 164 | "full_text": "rapaziada a firma não para depois de fazer o @robochorao o @phriaca fez o @/GlitchArtBot pra voces entrarem na a e s t h e t i c só marcando o bot em qualquer foto vou fazer o teste com a capa do mordida https://t.co/B57757hBgP", 165 | "truncated": false, 166 | "display_text_range": [ 167 | 0, 168 | 204 169 | ], 170 | "entities": { 171 | "hashtags": [], 172 | "symbols": [], 173 | "user_mentions": [ 174 | { 175 | "screen_name": "robochorao", 176 | "name": "chorão bot", 177 | "id": 1107419349734834184, 178 | "id_str": "1107419349734834184", 179 | "indices": [ 180 | 45, 181 | 56 182 | ] 183 | }, 184 | { 185 | "screen_name": "phriaca", 186 | "name": "🌫️", 187 | "id": 3306202054, 188 | "id_str": "3306202054", 189 | "indices": [ 190 | 59, 191 | 67 192 | ] 193 | } 194 | ], 195 | "urls": [], 196 | "media": [ 197 | { 198 | "id": 1235349026847027200, 199 | "id_str": "1235349026847027200", 200 | "indices": [ 201 | 205, 202 | 228 203 | ], 204 | "media_url": "http://pbs.twimg.com/media/ESTXZQ7XcAAVD3t.jpg", 205 | "media_url_https": "https://pbs.twimg.com/media/ESTXZQ7XcAAVD3t.jpg", 206 | "url": "https://t.co/B57757hBgP", 207 | "display_url": "pic.twitter.com/B57757hBgP", 208 | "expanded_url": "https://twitter.com/matheusmosa/status/1235349280120066049/photo/1", 209 | "type": "photo", 210 | "sizes": { 211 | "large": { 212 | "w": 1400, 213 | "h": 1400, 214 | "resize": "fit" 215 | }, 216 | "thumb": { 217 | "w": 150, 218 | "h": 150, 219 | "resize": "crop" 220 | }, 221 | "small": { 222 | "w": 680, 223 | "h": 680, 224 | "resize": "fit" 225 | }, 226 | "medium": { 227 | "w": 1200, 228 | "h": 1200, 229 | "resize": "fit" 230 | } 231 | } 232 | } 233 | ] 234 | }, 235 | "extended_entities": { 236 | "media": [ 237 | { 238 | "id": 1235349026847027200, 239 | "id_str": "1235349026847027200", 240 | "indices": [ 241 | 205, 242 | 228 243 | ], 244 | "media_url": "http://pbs.twimg.com/media/ESTXZQ7XcAAVD3t.jpg", 245 | "media_url_https": "https://pbs.twimg.com/media/ESTXZQ7XcAAVD3t.jpg", 246 | "url": "https://t.co/B57757hBgP", 247 | "display_url": "pic.twitter.com/B57757hBgP", 248 | "expanded_url": "https://twitter.com/matheusmosa/status/1235349280120066049/photo/1", 249 | "type": "photo", 250 | "sizes": { 251 | "large": { 252 | "w": 1400, 253 | "h": 1400, 254 | "resize": "fit" 255 | }, 256 | "thumb": { 257 | "w": 150, 258 | "h": 150, 259 | "resize": "crop" 260 | }, 261 | "small": { 262 | "w": 680, 263 | "h": 680, 264 | "resize": "fit" 265 | }, 266 | "medium": { 267 | "w": 1200, 268 | "h": 1200, 269 | "resize": "fit" 270 | } 271 | } 272 | } 273 | ] 274 | }, 275 | "source": "Twitter Web App", 276 | "in_reply_to_status_id": null, 277 | "in_reply_to_status_id_str": null, 278 | "in_reply_to_user_id": null, 279 | "in_reply_to_user_id_str": null, 280 | "in_reply_to_screen_name": null, 281 | "user": { 282 | "id": 702953494144294912, 283 | "id_str": "702953494144294912", 284 | "name": "ch̶á̶ d̸̸̡̧e h͘͘͟ortelã", 285 | "screen_name": "matheusmosa", 286 | "location": "Rio de Janeiro, Brasil", 287 | "description": "musicas, glitchs e merdas postadask̙̰k̷͉͙̖͇kk̗̜͓̜", 288 | "url": "https://t.co/ijHr3jpKUZ", 289 | "entities": { 290 | "url": { 291 | "urls": [ 292 | { 293 | "url": "https://t.co/ijHr3jpKUZ", 294 | "expanded_url": "https://open.spotify.com/album/1TRShbIYZiTDvYnApeC3G8?si=WzB-jXwmRmy7ctx46aVbnA", 295 | "display_url": "open.spotify.com/album/1TRShbIY…", 296 | "indices": [ 297 | 0, 298 | 23 299 | ] 300 | } 301 | ] 302 | }, 303 | "description": { 304 | "urls": [] 305 | } 306 | }, 307 | "protected": false, 308 | "followers_count": 258, 309 | "friends_count": 185, 310 | "listed_count": 0, 311 | "created_at": "Thu Feb 25 20:29:08 +0000 2016", 312 | "favourites_count": 8132, 313 | "utc_offset": null, 314 | "time_zone": null, 315 | "geo_enabled": true, 316 | "verified": false, 317 | "statuses_count": 8771, 318 | "lang": null, 319 | "contributors_enabled": false, 320 | "is_translator": false, 321 | "is_translation_enabled": false, 322 | "profile_background_color": "000000", 323 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 324 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 325 | "profile_background_tile": false, 326 | "profile_image_url": "http://pbs.twimg.com/profile_images/1233618530710499329/RZHYcQ9m_normal.jpg", 327 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/1233618530710499329/RZHYcQ9m_normal.jpg", 328 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/702953494144294912/1534305675", 329 | "profile_link_color": "000000", 330 | "profile_sidebar_border_color": "000000", 331 | "profile_sidebar_fill_color": "000000", 332 | "profile_text_color": "000000", 333 | "profile_use_background_image": false, 334 | "has_extended_profile": true, 335 | "default_profile": false, 336 | "default_profile_image": false, 337 | "following": false, 338 | "follow_request_sent": false, 339 | "notifications": false, 340 | "translator_type": "none" 341 | }, 342 | "geo": null, 343 | "coordinates": null, 344 | "place": null, 345 | "contributors": null, 346 | "is_quote_status": false, 347 | "retweet_count": 0, 348 | "favorite_count": 4, 349 | "favorited": false, 350 | "retweeted": false, 351 | "possibly_sensitive": false, 352 | "possibly_sensitive_appealable": false, 353 | "lang": "pt" 354 | } 355 | 356 | const noMediaExtended: Tweet = { 357 | "created_at": "Wed Feb 26 01:23:57 +0000 2020", 358 | "id": 1232476361320587265, 359 | "id_str": "1232476361320587265", 360 | "full_text": "Hello, does anyone out there in Facebook land know about the rap band \"Death Grips\"? Charlie is begging me for one of their t-shirts and a quick Google turned up some inappropriate imagery: breasts exposed, violence, destruction. One shirt even had a man's 'wand-dang-doodle' on i", 361 | "truncated": false, 362 | "display_text_range": [ 363 | 0, 364 | 280 365 | ], 366 | "entities": { 367 | "hashtags": [], 368 | "symbols": [], 369 | "user_mentions": [], 370 | "urls": [] 371 | }, 372 | "source": "Twitter Web App", 373 | "in_reply_to_status_id": null, 374 | "in_reply_to_status_id_str": null, 375 | "in_reply_to_user_id": null, 376 | "in_reply_to_user_id_str": null, 377 | "in_reply_to_screen_name": null, 378 | "user": { 379 | "id": 3306202054, 380 | "id_str": "3306202054", 381 | "name": "🌫️", 382 | "screen_name": "phriaca", 383 | "location": "", 384 | "description": "", 385 | "url": null, 386 | "entities": { 387 | "description": { 388 | "urls": [] 389 | } 390 | }, 391 | "protected": false, 392 | "followers_count": 140, 393 | "friends_count": 143, 394 | "listed_count": 1, 395 | "created_at": "Tue Jun 02 00:09:02 +0000 2015", 396 | "favourites_count": 9480, 397 | "utc_offset": null, 398 | "time_zone": null, 399 | "geo_enabled": false, 400 | "verified": false, 401 | "statuses_count": 5919, 402 | "lang": null, 403 | "contributors_enabled": false, 404 | "is_translator": false, 405 | "is_translation_enabled": false, 406 | "profile_background_color": "ACDED6", 407 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme18/bg.gif", 408 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme18/bg.gif", 409 | "profile_background_tile": false, 410 | "profile_image_url": "http://pbs.twimg.com/profile_images/1177315024940994562/jC46v6-J_normal.jpg", 411 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/1177315024940994562/jC46v6-J_normal.jpg", 412 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/3306202054/1581211944", 413 | "profile_link_color": "00163A", 414 | "profile_sidebar_border_color": "000000", 415 | "profile_sidebar_fill_color": "000000", 416 | "profile_text_color": "000000", 417 | "profile_use_background_image": true, 418 | "has_extended_profile": false, 419 | "default_profile": false, 420 | "default_profile_image": false, 421 | "following": true, 422 | "follow_request_sent": false, 423 | "notifications": false, 424 | "translator_type": "none" 425 | }, 426 | "geo": null, 427 | "coordinates": null, 428 | "place": null, 429 | "contributors": null, 430 | "is_quote_status": false, 431 | "retweet_count": 0, 432 | "favorite_count": 1, 433 | "favorited": false, 434 | "retweeted": false, 435 | "lang": "en" 436 | } 437 | 438 | const noMediaNotExtended: Tweet = { 439 | "created_at": "Wed Feb 26 01:26:12 +0000 2020", 440 | "id": 1232476926926708737, 441 | "id_str": "1232476926926708737", 442 | "text": "t!!!! Our kids and teens are listening to this and seeing this. A warning to all parents, keep this band away and o… https://t.co/ozlGfMZAsu", 443 | "truncated": true, 444 | "entities": { 445 | "hashtags": [], 446 | "symbols": [], 447 | "user_mentions": [], 448 | "urls": [ 449 | { 450 | "url": "https://t.co/ozlGfMZAsu", 451 | "expanded_url": "https://twitter.com/i/web/status/1232476926926708737", 452 | "display_url": "twitter.com/i/web/status/1…", 453 | "indices": [ 454 | 117, 455 | 140 456 | ] 457 | } 458 | ] 459 | }, 460 | "source": "Twitter Web App", 461 | "in_reply_to_status_id": 1232476361320587265, 462 | "in_reply_to_status_id_str": "1232476361320587265", 463 | "in_reply_to_user_id": 3306202054, 464 | "in_reply_to_user_id_str": "3306202054", 465 | "in_reply_to_screen_name": "phriaca", 466 | "user": { 467 | "id": 3306202054, 468 | "id_str": "3306202054", 469 | "name": "🌫️", 470 | "screen_name": "phriaca", 471 | "location": "", 472 | "description": "", 473 | "url": null, 474 | "entities": { 475 | "description": { 476 | "urls": [] 477 | } 478 | }, 479 | "protected": false, 480 | "followers_count": 140, 481 | "friends_count": 143, 482 | "listed_count": 1, 483 | "created_at": "Tue Jun 02 00:09:02 +0000 2015", 484 | "favourites_count": 9480, 485 | "utc_offset": null, 486 | "time_zone": null, 487 | "geo_enabled": false, 488 | "verified": false, 489 | "statuses_count": 5919, 490 | "lang": null, 491 | "contributors_enabled": false, 492 | "is_translator": false, 493 | "is_translation_enabled": false, 494 | "profile_background_color": "ACDED6", 495 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme18/bg.gif", 496 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme18/bg.gif", 497 | "profile_background_tile": false, 498 | "profile_image_url": "http://pbs.twimg.com/profile_images/1177315024940994562/jC46v6-J_normal.jpg", 499 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/1177315024940994562/jC46v6-J_normal.jpg", 500 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/3306202054/1581211944", 501 | "profile_link_color": "00163A", 502 | "profile_sidebar_border_color": "000000", 503 | "profile_sidebar_fill_color": "000000", 504 | "profile_text_color": "000000", 505 | "profile_use_background_image": true, 506 | "has_extended_profile": false, 507 | "default_profile": false, 508 | "default_profile_image": false, 509 | "following": true, 510 | "follow_request_sent": false, 511 | "notifications": false, 512 | "translator_type": "none" 513 | }, 514 | "geo": null, 515 | "coordinates": null, 516 | "place": null, 517 | "contributors": null, 518 | "is_quote_status": false, 519 | "retweet_count": 0, 520 | "favorite_count": 0, 521 | "favorited": false, 522 | "retweeted": false, 523 | "lang": "en" 524 | } 525 | 526 | const sonTweet: Tweet = { 527 | "created_at": "Thu Mar 05 01:30:44 +0000 2020", 528 | "id": 1235377172883402753, 529 | "id_str": "1235377172883402753", 530 | "text": "@tthisolddog relaxa man é só tirar a dilma que o dolar chega em 2 dinheiros", 531 | "truncated": false, 532 | "entities": { 533 | "hashtags": [], 534 | "symbols": [], 535 | "user_mentions": [ 536 | { 537 | "screen_name": "tthisolddog", 538 | "name": "pedro pedro pedro pedro pedro pedro pedro pedro pe", 539 | "id": 547740103, 540 | "id_str": "547740103", 541 | "indices": [ 542 | 0, 543 | 12 544 | ] 545 | } 546 | ], 547 | "urls": [] 548 | }, 549 | "source": "Twitter for Android", 550 | "in_reply_to_status_id": 1235375942924632064, 551 | "in_reply_to_status_id_str": "1235375942924632064", 552 | "in_reply_to_user_id": 547740103, 553 | "in_reply_to_user_id_str": "547740103", 554 | "in_reply_to_screen_name": "tthisolddog", 555 | "user": { 556 | "id": 3306202054, 557 | "id_str": "3306202054", 558 | "name": "🌫️", 559 | "screen_name": "phriaca", 560 | "location": "", 561 | "description": "", 562 | "url": null, 563 | "entities": { 564 | "description": { 565 | "urls": [] 566 | } 567 | }, 568 | "protected": false, 569 | "followers_count": 139, 570 | "friends_count": 142, 571 | "listed_count": 1, 572 | "created_at": "Tue Jun 02 00:09:02 +0000 2015", 573 | "favourites_count": 9483, 574 | "utc_offset": null, 575 | "time_zone": null, 576 | "geo_enabled": false, 577 | "verified": false, 578 | "statuses_count": 5919, 579 | "lang": null, 580 | "contributors_enabled": false, 581 | "is_translator": false, 582 | "is_translation_enabled": false, 583 | "profile_background_color": "ACDED6", 584 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme18/bg.gif", 585 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme18/bg.gif", 586 | "profile_background_tile": false, 587 | "profile_image_url": "http://pbs.twimg.com/profile_images/1177315024940994562/jC46v6-J_normal.jpg", 588 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/1177315024940994562/jC46v6-J_normal.jpg", 589 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/3306202054/1581211944", 590 | "profile_link_color": "00163A", 591 | "profile_sidebar_border_color": "000000", 592 | "profile_sidebar_fill_color": "000000", 593 | "profile_text_color": "000000", 594 | "profile_use_background_image": true, 595 | "has_extended_profile": false, 596 | "default_profile": false, 597 | "default_profile_image": false, 598 | "following": true, 599 | "follow_request_sent": false, 600 | "notifications": false, 601 | "translator_type": "none" 602 | }, 603 | "geo": null, 604 | "coordinates": null, 605 | "place": null, 606 | "contributors": null, 607 | "is_quote_status": false, 608 | "retweet_count": 0, 609 | "favorite_count": 2, 610 | "favorited": false, 611 | "retweeted": false, 612 | "lang": "pt" 613 | } 614 | 615 | const orphanTweet: Tweet = { 616 | "created_at": "Thu Mar 05 01:25:51 +0000 2020", 617 | "id": 1235375942924632064, 618 | "id_str": "1235375942924632064", 619 | "text": "so hoje o dólar subiu 7 centavo KKKKKKKKKKKKKKKKKKKKKKK", 620 | "truncated": false, 621 | "entities": { 622 | "hashtags": [], 623 | "symbols": [], 624 | "user_mentions": [], 625 | "urls": [] 626 | }, 627 | "source": "Twitter for Android", 628 | "in_reply_to_status_id": null, 629 | "in_reply_to_status_id_str": null, 630 | "in_reply_to_user_id": null, 631 | "in_reply_to_user_id_str": null, 632 | "in_reply_to_screen_name": null, 633 | "user": { 634 | "id": 547740103, 635 | "id_str": "547740103", 636 | "name": "...and the gods made love", 637 | "screen_name": "tthisolddog", 638 | "location": "fundo do poço", 639 | "description": "now there's a look in your eyes\nlike black holes in the sky", 640 | "url": null, 641 | "entities": { 642 | "description": { 643 | "urls": [] 644 | } 645 | }, 646 | "protected": false, 647 | "followers_count": 777, 648 | "friends_count": 1213, 649 | "listed_count": 6, 650 | "created_at": "Sat Apr 07 16:39:55 +0000 2012", 651 | "favourites_count": 13752, 652 | "utc_offset": null, 653 | "time_zone": null, 654 | "geo_enabled": true, 655 | "verified": false, 656 | "statuses_count": 16855, 657 | "lang": null, 658 | "contributors_enabled": false, 659 | "is_translator": false, 660 | "is_translation_enabled": false, 661 | "profile_background_color": "131516", 662 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", 663 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", 664 | "profile_background_tile": true, 665 | "profile_image_url": "http://pbs.twimg.com/profile_images/1228348576058703872/SpqGee2l_normal.jpg", 666 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/1228348576058703872/SpqGee2l_normal.jpg", 667 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/547740103/1577630751", 668 | "profile_link_color": "000000", 669 | "profile_sidebar_border_color": "000000", 670 | "profile_sidebar_fill_color": "EFEFEF", 671 | "profile_text_color": "333333", 672 | "profile_use_background_image": true, 673 | "has_extended_profile": false, 674 | "default_profile": false, 675 | "default_profile_image": false, 676 | "following": false, 677 | "follow_request_sent": false, 678 | "notifications": false, 679 | "translator_type": "none" 680 | }, 681 | "geo": null, 682 | "coordinates": null, 683 | "place": null, 684 | "contributors": null, 685 | "is_quote_status": false, 686 | "retweet_count": 0, 687 | "favorite_count": 5, 688 | "favorited": false, 689 | "retweeted": false, 690 | "lang": "pt" 691 | } 692 | 693 | const multipleMediaExtended: Tweet = { 694 | "created_at": "Fri Mar 06 20:09:32 +0000 2020", 695 | "id": 1236021113504792576, 696 | "id_str": "1236021113504792576", 697 | "full_text": "alo. to vendendo uma webcam logitech c920 pro HD 1080p pq eu ia fazer stream com webcam mas não pretendo mais fazer por agora pq não me sinto bem fazendo stream com webcam então vou vender pq preciso de dinheiro pra tocar uns projetos. menos de 1 mes de uso frete gratis chama DM https://t.co/oL1bOGtTvN", 698 | "truncated": false, 699 | "display_text_range": [ 700 | 0, 701 | 280 702 | ], 703 | "entities": { 704 | "hashtags": [], 705 | "symbols": [], 706 | "user_mentions": [], 707 | "urls": [], 708 | "media": [ 709 | { 710 | "id": 1236021101802729473, 711 | "id_str": "1236021101802729473", 712 | "indices": [ 713 | 281, 714 | 304 715 | ], 716 | "media_url": "http://pbs.twimg.com/media/ESc6pLPWsAEC8Ln.jpg", 717 | "media_url_https": "https://pbs.twimg.com/media/ESc6pLPWsAEC8Ln.jpg", 718 | "url": "https://t.co/oL1bOGtTvN", 719 | "display_url": "pic.twitter.com/oL1bOGtTvN", 720 | "expanded_url": "https://twitter.com/kuramaboy_/status/1236021113504792576/photo/1", 721 | "type": "photo", 722 | "sizes": { 723 | "small": { 724 | "w": 359, 725 | "h": 680, 726 | "resize": "fit" 727 | }, 728 | "thumb": { 729 | "w": 150, 730 | "h": 150, 731 | "resize": "crop" 732 | }, 733 | "medium": { 734 | "w": 634, 735 | "h": 1200, 736 | "resize": "fit" 737 | }, 738 | "large": { 739 | "w": 720, 740 | "h": 1362, 741 | "resize": "fit" 742 | } 743 | } 744 | } 745 | ] 746 | }, 747 | "extended_entities": { 748 | "media": [ 749 | { 750 | "id": 1236021101802729473, 751 | "id_str": "1236021101802729473", 752 | "indices": [ 753 | 281, 754 | 304 755 | ], 756 | "media_url": "http://pbs.twimg.com/media/ESc6pLPWsAEC8Ln.jpg", 757 | "media_url_https": "https://pbs.twimg.com/media/ESc6pLPWsAEC8Ln.jpg", 758 | "url": "https://t.co/oL1bOGtTvN", 759 | "display_url": "pic.twitter.com/oL1bOGtTvN", 760 | "expanded_url": "https://twitter.com/kuramaboy_/status/1236021113504792576/photo/1", 761 | "type": "photo", 762 | "sizes": { 763 | "small": { 764 | "w": 359, 765 | "h": 680, 766 | "resize": "fit" 767 | }, 768 | "thumb": { 769 | "w": 150, 770 | "h": 150, 771 | "resize": "crop" 772 | }, 773 | "medium": { 774 | "w": 634, 775 | "h": 1200, 776 | "resize": "fit" 777 | }, 778 | "large": { 779 | "w": 720, 780 | "h": 1362, 781 | "resize": "fit" 782 | } 783 | } 784 | }, 785 | { 786 | "id": 1236021108874326017, 787 | "id_str": "1236021108874326017", 788 | "indices": [ 789 | 281, 790 | 304 791 | ], 792 | "media_url": "http://pbs.twimg.com/media/ESc6pllWsAEirIo.jpg", 793 | "media_url_https": "https://pbs.twimg.com/media/ESc6pllWsAEirIo.jpg", 794 | "url": "https://t.co/oL1bOGtTvN", 795 | "display_url": "pic.twitter.com/oL1bOGtTvN", 796 | "expanded_url": "https://twitter.com/kuramaboy_/status/1236021113504792576/photo/1", 797 | "type": "photo", 798 | "sizes": { 799 | "thumb": { 800 | "w": 150, 801 | "h": 150, 802 | "resize": "crop" 803 | }, 804 | "small": { 805 | "w": 548, 806 | "h": 680, 807 | "resize": "fit" 808 | }, 809 | "medium": { 810 | "w": 720, 811 | "h": 894, 812 | "resize": "fit" 813 | }, 814 | "large": { 815 | "w": 720, 816 | "h": 894, 817 | "resize": "fit" 818 | } 819 | } 820 | } 821 | ] 822 | }, 823 | "source": "Twitter for Android", 824 | "in_reply_to_status_id": null, 825 | "in_reply_to_status_id_str": null, 826 | "in_reply_to_user_id": null, 827 | "in_reply_to_user_id_str": null, 828 | "in_reply_to_screen_name": null, 829 | "user": { 830 | "id": 3207445696, 831 | "id_str": "3207445696", 832 | "name": "lucas 🔪", 833 | "screen_name": "kuramaboy_", 834 | "location": "mg. | @elementalita 🤧💖", 835 | "description": "juro que to tentando melhorar ta / Founder/CEO @hypestudioss", 836 | "url": "https://t.co/qgQZCbhkzc", 837 | "entities": { 838 | "url": { 839 | "urls": [ 840 | { 841 | "url": "https://t.co/qgQZCbhkzc", 842 | "expanded_url": "http://twitch.tv/kuramaboyyy", 843 | "display_url": "twitch.tv/kuramaboyyy", 844 | "indices": [ 845 | 0, 846 | 23 847 | ] 848 | } 849 | ] 850 | }, 851 | "description": { 852 | "urls": [] 853 | } 854 | }, 855 | "protected": false, 856 | "followers_count": 35974, 857 | "friends_count": 727, 858 | "listed_count": 15, 859 | "created_at": "Sat Apr 25 19:36:28 +0000 2015", 860 | "favourites_count": 13201, 861 | "utc_offset": null, 862 | "time_zone": null, 863 | "geo_enabled": true, 864 | "verified": false, 865 | "statuses_count": 56160, 866 | "lang": null, 867 | "contributors_enabled": false, 868 | "is_translator": false, 869 | "is_translation_enabled": false, 870 | "profile_background_color": "000000", 871 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 872 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 873 | "profile_background_tile": false, 874 | "profile_image_url": "http://pbs.twimg.com/profile_images/1228512400267194368/Is2IW1Hw_normal.jpg", 875 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/1228512400267194368/Is2IW1Hw_normal.jpg", 876 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/3207445696/1568653414", 877 | "profile_link_color": "ABB8C2", 878 | "profile_sidebar_border_color": "000000", 879 | "profile_sidebar_fill_color": "000000", 880 | "profile_text_color": "000000", 881 | "profile_use_background_image": false, 882 | "has_extended_profile": true, 883 | "default_profile": false, 884 | "default_profile_image": false, 885 | "following": false, 886 | "follow_request_sent": false, 887 | "notifications": false, 888 | "translator_type": "none" 889 | }, 890 | "geo": null, 891 | "coordinates": null, 892 | "place": null, 893 | "contributors": null, 894 | "is_quote_status": false, 895 | "retweet_count": 12, 896 | "favorite_count": 114, 897 | "favorited": false, 898 | "retweeted": false, 899 | "possibly_sensitive": false, 900 | "possibly_sensitive_appealable": false, 901 | "lang": "pt" 902 | } 903 | 904 | const multipleMediaNotExtended: Tweet = { 905 | "created_at": "Mon Apr 20 00:09:26 +0000 2020", 906 | "id": 1252026551790637057, 907 | "id_str": "1252026551790637057", 908 | "text": "@Cloud9 C9 after winning finals https://t.co/9maxsveNwr", 909 | "truncated": false, 910 | "entities": { 911 | "hashtags": [], 912 | "symbols": [], 913 | "user_mentions": [ 914 | { 915 | "screen_name": "Cloud9", 916 | "name": "Cloud9 vs COVID19", 917 | "id": 1452520626, 918 | "id_str": "1452520626", 919 | "indices": [ 920 | 0, 921 | 7 922 | ] 923 | } 924 | ], 925 | "urls": [], 926 | "media": [ 927 | { 928 | "id": 1252026548678463495, 929 | "id_str": "1252026548678463495", 930 | "indices": [ 931 | 32, 932 | 55 933 | ], 934 | "media_url": "http://pbs.twimg.com/media/EWAXgzNWkAcpjvs.jpg", 935 | "media_url_https": "https://pbs.twimg.com/media/EWAXgzNWkAcpjvs.jpg", 936 | "url": "https://t.co/9maxsveNwr", 937 | "display_url": "pic.twitter.com/9maxsveNwr", 938 | "expanded_url": "https://twitter.com/lolYisus/status/1252026551790637057/photo/1", 939 | "type": "photo", 940 | "sizes": { 941 | "thumb": { 942 | "w": 150, 943 | "h": 150, 944 | "resize": "crop" 945 | }, 946 | "large": { 947 | "w": 279, 948 | "h": 157, 949 | "resize": "fit" 950 | }, 951 | "medium": { 952 | "w": 279, 953 | "h": 157, 954 | "resize": "fit" 955 | }, 956 | "small": { 957 | "w": 279, 958 | "h": 157, 959 | "resize": "fit" 960 | } 961 | } 962 | } 963 | ] 964 | }, 965 | "extended_entities": { 966 | "media": [ 967 | { 968 | "id": 1252026548678463495, 969 | "id_str": "1252026548678463495", 970 | "indices": [ 971 | 32, 972 | 55 973 | ], 974 | "media_url": "http://pbs.twimg.com/media/EWAXgzNWkAcpjvs.jpg", 975 | "media_url_https": "https://pbs.twimg.com/media/EWAXgzNWkAcpjvs.jpg", 976 | "url": "https://t.co/9maxsveNwr", 977 | "display_url": "pic.twitter.com/9maxsveNwr", 978 | "expanded_url": "https://twitter.com/lolYisus/status/1252026551790637057/photo/1", 979 | "type": "photo", 980 | "sizes": { 981 | "thumb": { 982 | "w": 150, 983 | "h": 150, 984 | "resize": "crop" 985 | }, 986 | "large": { 987 | "w": 279, 988 | "h": 157, 989 | "resize": "fit" 990 | }, 991 | "medium": { 992 | "w": 279, 993 | "h": 157, 994 | "resize": "fit" 995 | }, 996 | "small": { 997 | "w": 279, 998 | "h": 157, 999 | "resize": "fit" 1000 | } 1001 | } 1002 | }, 1003 | { 1004 | "id": 1252026548707868672, 1005 | "id_str": "1252026548707868672", 1006 | "indices": [ 1007 | 32, 1008 | 55 1009 | ], 1010 | "media_url": "http://pbs.twimg.com/media/EWAXgzUXQAA2R63.jpg", 1011 | "media_url_https": "https://pbs.twimg.com/media/EWAXgzUXQAA2R63.jpg", 1012 | "url": "https://t.co/9maxsveNwr", 1013 | "display_url": "pic.twitter.com/9maxsveNwr", 1014 | "expanded_url": "https://twitter.com/lolYisus/status/1252026551790637057/photo/1", 1015 | "type": "photo", 1016 | "sizes": { 1017 | "small": { 1018 | "w": 315, 1019 | "h": 177, 1020 | "resize": "fit" 1021 | }, 1022 | "thumb": { 1023 | "w": 150, 1024 | "h": 150, 1025 | "resize": "crop" 1026 | }, 1027 | "medium": { 1028 | "w": 315, 1029 | "h": 177, 1030 | "resize": "fit" 1031 | }, 1032 | "large": { 1033 | "w": 315, 1034 | "h": 177, 1035 | "resize": "fit" 1036 | } 1037 | } 1038 | } 1039 | ] 1040 | }, 1041 | "source": "Twitter for iPhone", 1042 | "in_reply_to_status_id": 1252024929106755585, 1043 | "in_reply_to_status_id_str": "1252024929106755585", 1044 | "in_reply_to_user_id": 1452520626, 1045 | "in_reply_to_user_id_str": "1452520626", 1046 | "in_reply_to_screen_name": "Cloud9", 1047 | "user": { 1048 | "id": 3430992539, 1049 | "id_str": "3430992539", 1050 | "name": "Yisus", 1051 | "screen_name": "lolYisus", 1052 | "location": "UConn", 1053 | "description": "Superstar Master Tier Jungler - streamer @ https://t.co/vkOZZ0Sstr - @YisusAlt", 1054 | "url": "https://t.co/MuQLDfbt46", 1055 | "entities": { 1056 | "url": { 1057 | "urls": [ 1058 | { 1059 | "url": "https://t.co/MuQLDfbt46", 1060 | "expanded_url": "https://www.youtube.com/mYisus?sub_confirmation=1", 1061 | "display_url": "youtube.com/mYisus?sub_con…", 1062 | "indices": [ 1063 | 0, 1064 | 23 1065 | ] 1066 | } 1067 | ] 1068 | }, 1069 | "description": { 1070 | "urls": [ 1071 | { 1072 | "url": "https://t.co/vkOZZ0Sstr", 1073 | "expanded_url": "http://twitch.tv/YisusNA", 1074 | "display_url": "twitch.tv/YisusNA", 1075 | "indices": [ 1076 | 43, 1077 | 66 1078 | ] 1079 | } 1080 | ] 1081 | } 1082 | }, 1083 | "protected": false, 1084 | "followers_count": 40665, 1085 | "friends_count": 568, 1086 | "listed_count": 42, 1087 | "created_at": "Wed Aug 19 04:58:39 +0000 2015", 1088 | "favourites_count": 72352, 1089 | "utc_offset": null, 1090 | "time_zone": null, 1091 | "geo_enabled": true, 1092 | "verified": false, 1093 | "statuses_count": 30211, 1094 | "lang": null, 1095 | "contributors_enabled": false, 1096 | "is_translator": false, 1097 | "is_translation_enabled": false, 1098 | "profile_background_color": "C0DEED", 1099 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 1100 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 1101 | "profile_background_tile": false, 1102 | "profile_image_url": "http://pbs.twimg.com/profile_images/1220928237947117568/bnZo0sCK_normal.jpg", 1103 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/1220928237947117568/bnZo0sCK_normal.jpg", 1104 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/3430992539/1559971896", 1105 | "profile_link_color": "1DA1F2", 1106 | "profile_sidebar_border_color": "C0DEED", 1107 | "profile_sidebar_fill_color": "DDEEF6", 1108 | "profile_text_color": "333333", 1109 | "profile_use_background_image": true, 1110 | "has_extended_profile": true, 1111 | "default_profile": true, 1112 | "default_profile_image": false, 1113 | "following": false, 1114 | "follow_request_sent": false, 1115 | "notifications": false, 1116 | "translator_type": "none" 1117 | }, 1118 | "geo": null, 1119 | "coordinates": null, 1120 | "place": null, 1121 | "contributors": null, 1122 | "is_quote_status": false, 1123 | "retweet_count": 98, 1124 | "favorite_count": 1571, 1125 | "favorited": false, 1126 | "retweeted": false, 1127 | "possibly_sensitive": false, 1128 | "possibly_sensitive_appealable": false, 1129 | "lang": "en" 1130 | } 1131 | 1132 | export { 1133 | mediaNotExtended, 1134 | mediaExtended, 1135 | noMediaExtended, 1136 | noMediaNotExtended, 1137 | sonTweet, 1138 | orphanTweet, 1139 | multipleMediaExtended, 1140 | multipleMediaNotExtended 1141 | } --------------------------------------------------------------------------------