├── .gitignore ├── .vscode └── settings.json ├── LICENSE.md ├── apps ├── ElPatoBot.Aws │ ├── .gitignore │ ├── package.json │ └── serverless.yml ├── ElPatoBot.Server │ ├── .eslintrc.json │ ├── package.json │ ├── src │ │ ├── InMemoryCache │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── quackRestrictor │ │ │ └── index.ts │ │ ├── repository │ │ │ ├── dbClient │ │ │ │ └── index.ts │ │ │ ├── quackRepository.ts │ │ │ └── userRespository.ts │ │ ├── settings.ts │ │ ├── twitchApi │ │ │ └── index.ts │ │ └── twitchClient │ │ │ └── index.ts │ └── tsconfig.json └── ElPatoBot.UI │ ├── .eslintrc.json │ ├── package.json │ ├── public │ ├── _redirects │ ├── audio │ │ └── quack-1.wav │ ├── img │ │ ├── DuckMouthClose.png │ │ ├── DuckMouthOpen.png │ │ ├── bg.png │ │ ├── copy.svg │ │ └── github.svg │ └── index.html │ ├── src │ ├── App │ │ └── index.tsx │ ├── components │ │ ├── atoms │ │ │ ├── InputText │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── Link │ │ │ │ └── index.tsx │ │ │ └── Typography │ │ │ │ └── index.tsx │ │ ├── molecules │ │ │ ├── ClickToCopy │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ └── QuackCard │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ ├── organism │ │ │ ├── Leaderboard │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ └── UserConfiguration │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ └── pages │ │ │ ├── BotPage │ │ │ └── index.tsx │ │ │ └── HomePage │ │ │ ├── index.tsx │ │ │ ├── particleOptions.ts │ │ │ └── styles.ts │ ├── index.css │ ├── index.tsx │ ├── settings.ts │ ├── theme.d.ts │ └── theme │ │ └── theme.ts │ ├── tsconfig.json │ └── yarn.lock ├── package-lock.json ├── package.json ├── packages ├── entity │ ├── index.ts │ └── package.json ├── events │ ├── index.ts │ └── package.json ├── responses │ ├── index.ts │ └── package.json ├── secrets │ ├── index.ts │ ├── package.json │ └── tokenCache.ts └── tsconfig │ ├── base.json │ ├── nextjs.json │ └── package.json ├── turbo.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | **/secrets.ts 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | .pnp 7 | .pnp.js 8 | 9 | # testing 10 | coverage 11 | 12 | # next.js 13 | .next/ 14 | out/ 15 | build 16 | 17 | # misc 18 | .DS_Store 19 | *.pem 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | .pnpm-debug.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # turbo 34 | .turbo 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/secrets.ts": true 4 | }, 5 | "cSpell.words": [ 6 | "elpatobot", 7 | "quackrank", 8 | "Restrictor", 9 | "twurple", 10 | "unmarshall" 11 | ] 12 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Open Pato License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | 1. **Attribution**: The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | 1. **Open Source Requirement**: Any derivative works, modifications, or distributions of the Software, in whole or in part, must be made available to the public as open-source software. The source code of such derivative works or distributions must be made publicly accessible and free of charge, and must be licensed under the same terms as this Open Pato License. 14 | 15 | 1. **Duck Protection Clause**: The use of this software, directly or indirectly, 16 | to harm ducks or any duck-related activities is strictly prohibited. Furthermore, 17 | any attempt to rename the software project to a name associated with geese, directly 18 | or indirectly, is also prohibited. 19 | 20 | 1. **AI Model Restriction Clause**: The use of the source code, art, sound or any assets in this software for training or 21 | operating artificial intelligence models, including but not limited to machine learning models, 22 | neural networks, or any other AI systems, is strictly prohibited. 23 | 24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 25 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 26 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 27 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 28 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 29 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 30 | SOFTWARE. -------------------------------------------------------------------------------- /apps/ElPatoBot.Aws/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .serverless 3 | -------------------------------------------------------------------------------- /apps/ElPatoBot.Aws/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elpatobot/aws", 3 | "version": "1.0.0", 4 | "description": "serverless deployment for elpatobot", 5 | "dependencies": { 6 | "express": "^4.17.1", 7 | "serverless-http": "^2.7.0" 8 | }, 9 | "scripts": { 10 | "deploy:dev": "serverless deploy --stage dev", 11 | "deploy:prd": "serverless deploy --stage prd" 12 | } 13 | } -------------------------------------------------------------------------------- /apps/ElPatoBot.Aws/serverless.yml: -------------------------------------------------------------------------------- 1 | org: niv3kelpato 2 | app: el-pato-bot-aws 3 | service: ElPatoBot-Aws 4 | frameworkVersion: '3' 5 | 6 | params: 7 | default: 8 | UserQuacks: ${self:service}-${sls:stage}-UserQuacks 9 | ChannelQuacks: ${self:service}-${sls:stage}-ChannelQuacks 10 | TopChannelQuacks: ${self:service}-${sls:stage}-TopChannelQuacks 11 | TopUserQuacks: ${self:service}-${sls:stage}-TopUserQuacks 12 | UserConfig: ${self:service}-${sls:stage}-UserConfig 13 | 14 | provider: 15 | name: aws 16 | region: eu-west-2 17 | runtime: nodejs16.x 18 | environment: 19 | USER_QUACKS: ${param:UserQuacks} 20 | CHANNEL_QUACKS: ${param:ChannelQuacks} 21 | TOP_USER_QUACKS: ${param:TopUserQuacks} 22 | TOP_CHANNEL_QUACKS: ${param:TopChannelQuacks} 23 | USER_CONFIG: ${param:UserConfig} 24 | iam: 25 | role: 26 | statements: 27 | - Effect: Allow 28 | Action: 29 | - dynamodb:Query 30 | - dynamodb:Scan 31 | - dynamodb:GetItem 32 | - dynamodb:PutItem 33 | - dynamodb:UpdateItem 34 | - dynamodb:DeleteItem 35 | Resource: 36 | - arn:aws:dynamodb:${aws:region}:${aws:accountId}:table/${param:UserQuacks} 37 | - arn:aws:dynamodb:${aws:region}:${aws:accountId}:table/${param:ChannelQuacks} 38 | - arn:aws:dynamodb:${aws:region}:${aws:accountId}:table/${param:UserConfig} 39 | 40 | resources: 41 | Resources: 42 | UserQuacks: 43 | Type: AWS::DynamoDB::Table 44 | Properties: 45 | AttributeDefinitions: 46 | - AttributeName: userId 47 | AttributeType: S 48 | KeySchema: 49 | - AttributeName: userId 50 | KeyType: HASH 51 | BillingMode: PAY_PER_REQUEST 52 | TableName: ${param:UserQuacks} 53 | ChannelQuacks: 54 | Type: AWS::DynamoDB::Table 55 | Properties: 56 | AttributeDefinitions: 57 | - AttributeName: userId 58 | AttributeType: S 59 | KeySchema: 60 | - AttributeName: userId 61 | KeyType: HASH 62 | BillingMode: PAY_PER_REQUEST 63 | TableName: ${param:ChannelQuacks} 64 | TopChannelQuacks: 65 | Type: AWS::DynamoDB::Table 66 | Properties: 67 | AttributeDefinitions: 68 | - AttributeName: userId 69 | AttributeType: S 70 | KeySchema: 71 | - AttributeName: userId 72 | KeyType: HASH 73 | BillingMode: PAY_PER_REQUEST 74 | TableName: ${param:TopChannelQuacks} 75 | TopUserQuacks: 76 | Type: AWS::DynamoDB::Table 77 | Properties: 78 | AttributeDefinitions: 79 | - AttributeName: userId 80 | AttributeType: S 81 | KeySchema: 82 | - AttributeName: userId 83 | KeyType: HASH 84 | BillingMode: PAY_PER_REQUEST 85 | TableName: ${param:TopUserQuacks} 86 | UserConfig: 87 | Type: AWS::DynamoDB::Table 88 | Properties: 89 | AttributeDefinitions: 90 | - AttributeName: userId 91 | AttributeType: S 92 | KeySchema: 93 | - AttributeName: userId 94 | KeyType: HASH 95 | BillingMode: PAY_PER_REQUEST 96 | TableName: ${param:UserConfig} -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "overrides": [ 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module" 16 | }, 17 | "plugins": [ 18 | "@typescript-eslint" 19 | ], 20 | "rules": { 21 | "indent": [ 22 | "error", 23 | 4 24 | ], 25 | "quotes": [ 26 | "error", 27 | "single" 28 | ], 29 | "semi": [ 30 | "error", 31 | "always" 32 | ] 33 | } 34 | } -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elpatobot/server", 3 | "version": "1.0.0", 4 | "main": "./src/index.tsx", 5 | "dependencies": { 6 | "@aws-sdk/client-dynamodb": "^3.215.0", 7 | "@aws-sdk/lib-dynamodb": "^3.215.0", 8 | "@elpatobot/responses": "*", 9 | "@elpatobot/secrets": "*", 10 | "@koa/cors": "^4.0.0", 11 | "@koa/router": "^12.0.0", 12 | "@twurple/api": "^6.1.1", 13 | "@twurple/auth": "^6.1.1", 14 | "@twurple/chat": "^6.1.1", 15 | "@types/koa": "^2.13.5", 16 | "@types/koa__cors": "^3.3.0", 17 | "@types/koa__router": "^12.0.0", 18 | "@types/node": "^16.7.13", 19 | "@types/node-fetch": "^2.6.2", 20 | "@types/tmi.js": "^1.8.2", 21 | "@types/ws": "^8.5.3", 22 | "axios": "^1.2.0", 23 | "cross-env": "^7.0.3", 24 | "iso-8859-2": "^3.0.4", 25 | "koa": "^2.13.4", 26 | "koa-body": "^6.0.1", 27 | "node-fetch": "2", 28 | "ts-node": "^10.9.1", 29 | "ws": "^8.11.0", 30 | "@elpatobot/events": "*", 31 | "@elpatobot/ts": "*", 32 | "@elpatobot/entity": "*" 33 | }, 34 | "scripts": { 35 | "start": "cross-env env=dev ts-node ./src/index.ts", 36 | "start:dev": "cross-env env=dev ts-node ./src/index.ts", 37 | "start:prd": "cross-env env=prd ts-node ./src/index.ts", 38 | "clean": "rimraf node_modules" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/src/InMemoryCache/index.ts: -------------------------------------------------------------------------------- 1 | // cada quack se guarda in memory 2 | // cada 5m actualizar db 3 | // si no esta en el cache leemos db 4 | // si esta en cache, actualizar cache 5 | 6 | import quackRepository, { QuackEntity } from "../repository/quackRepository"; 7 | 8 | const DB_UPDATE_SYNC = 10000; 9 | const MAX_TOP_ITEMS = 10; 10 | 11 | class InMemoryCache { 12 | private users: Record; 13 | private channels: Record; 14 | public topUsers: Array; 15 | public topChannels: Array; 16 | private topHasChanged: boolean; 17 | 18 | constructor() { 19 | this.users = {}; 20 | this.channels = {}; 21 | this.topChannels = []; 22 | this.topUsers = []; 23 | this.getTopFromDb(); 24 | this.topHasChanged = false; 25 | setInterval(this.updateDatabase, DB_UPDATE_SYNC) 26 | } 27 | 28 | private getTopFromDb = async () => { 29 | this.topChannels = []; 30 | const channelQuacks = await quackRepository.getTopChannelQuacks() 31 | for (const channel of channelQuacks){ 32 | const quacks = await quackRepository.getQuacksForChannel(channel.userId); 33 | this.topChannels.push({ 34 | quackCount: quacks, 35 | userId: channel.userId 36 | }) 37 | } 38 | 39 | this.topUsers = []; 40 | const userQuacks = await quackRepository.getTopUserQuacks() 41 | for (const user of userQuacks){ 42 | const quacks = await quackRepository.getQuacksForUser(user.userId); 43 | this.topUsers.push({ 44 | quackCount: quacks, 45 | userId: user.userId 46 | }) 47 | } 48 | } 49 | 50 | private updateDatabase = async () => { 51 | try { 52 | if (this.topHasChanged) { 53 | await quackRepository.updateTopChannelQuacks(this.topChannels 54 | .sort((c) => c.quackCount) 55 | .slice(0, MAX_TOP_ITEMS) 56 | .map((c) => c.userId)); 57 | await quackRepository.updateTopUserQuacks(this.topUsers 58 | .sort(u => u.quackCount) 59 | .slice(0, MAX_TOP_ITEMS) 60 | .map((u) => u.userId)); 61 | this.topHasChanged = false; 62 | } 63 | 64 | for (const key of Object.keys(this.users)){ 65 | const quacks = this.users[key]; 66 | await quackRepository.setUserQuacks(key, quacks); 67 | delete this.users[key]; 68 | } 69 | 70 | for (const key of Object.keys(this.channels)){ 71 | const quacks = this.channels[key]; 72 | console.log(this.channels); 73 | console.log(key, quacks); 74 | await quackRepository.setChannelQuacks(key, quacks); 75 | delete this.channels[key]; 76 | } 77 | } catch (e) { 78 | console.log('Error on interval:', e); 79 | } 80 | } 81 | 82 | private getUserQuacks = async (userId:string) => { 83 | const existingUserCacheItem = this.users[userId]; 84 | if (existingUserCacheItem) return existingUserCacheItem; 85 | const quacksFromDb = await quackRepository.getQuacksForUser(userId); 86 | this.users[userId] = quacksFromDb; 87 | return quacksFromDb 88 | } 89 | 90 | private getChannelQuacks = async (userId:string) => { 91 | const existingChannelCacheItem = this.channels[userId]; 92 | if (existingChannelCacheItem) return existingChannelCacheItem; 93 | const quacksFromDb = await quackRepository.getQuacksForChannel(userId); 94 | this.channels[userId] = quacksFromDb; 95 | return quacksFromDb 96 | } 97 | 98 | private updateInternalUserTopQuacks = async (userId:string, quackCount:number) => { 99 | // si existe lo actualizamos 100 | const existingInTop = this.topUsers.find((u) => u.userId === userId); 101 | if (existingInTop) { 102 | this.topUsers.forEach((u) => { 103 | if (u.userId === userId){ 104 | u.quackCount = quackCount 105 | } 106 | }); 107 | this.topHasChanged = true; 108 | return; 109 | } 110 | 111 | // si es menos lo agregamos sin mas 112 | if (this.topUsers.length < MAX_TOP_ITEMS) { 113 | this.topUsers.push({ quackCount, userId }); 114 | this.topHasChanged = true; 115 | return; 116 | } 117 | 118 | // reverse sort 119 | const sortedQuacks = this.topUsers 120 | .sort((a,b) => b.quackCount - a.quackCount); 121 | 122 | // if less than limit 123 | const lastItem = sortedQuacks[sortedQuacks.length - 1]; 124 | if (quackCount > lastItem.quackCount) { 125 | this.topHasChanged = true; 126 | sortedQuacks.pop(); 127 | sortedQuacks.push({ quackCount, userId }); 128 | this.topUsers = sortedQuacks; 129 | } 130 | } 131 | 132 | private updateInternalChannelTopQuacks = async (userId:string, quackCount:number) => { 133 | // si existe lo actualizamos 134 | const existingInTop = this.topChannels.find((u) => u.userId === userId); 135 | if (existingInTop) { 136 | this.topChannels.forEach((u) => { 137 | if (u.userId === userId){ 138 | u.quackCount = quackCount 139 | } 140 | }); 141 | this.topHasChanged = true; 142 | return; 143 | } 144 | 145 | // si es menos lo agregamos sin mas 146 | if (this.topChannels.length < (MAX_TOP_ITEMS - 1)) { 147 | this.topChannels.push({ quackCount, userId }); 148 | this.topHasChanged = true; 149 | return; 150 | } 151 | 152 | // reverse sort 153 | const sortedQuacks = this.topChannels 154 | .sort((a,b) => b.quackCount - a.quackCount); 155 | 156 | // if less than limit 157 | const lastItem = sortedQuacks[sortedQuacks.length - 1]; 158 | if (quackCount > lastItem.quackCount) { 159 | this.topChannels = [...this.topChannels 160 | .filter(u => u.userId !== lastItem.userId), { 161 | quackCount, 162 | userId 163 | }]; 164 | this.topHasChanged = true; 165 | } 166 | } 167 | 168 | public addOneQuack = async (userId:string, channel:string) => { 169 | this.users[userId] = await (this.getUserQuacks(userId)) + 1; 170 | this.channels[channel] = await (this.getChannelQuacks(channel)) + 1; 171 | this.updateInternalChannelTopQuacks(channel, this.channels[channel]); 172 | this.updateInternalUserTopQuacks(userId, this.users[userId]); 173 | } 174 | } 175 | 176 | export default InMemoryCache; -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/src/index.ts: -------------------------------------------------------------------------------- 1 | import cors from '@koa/cors'; 2 | import Router from '@koa/router'; 3 | import Koa from 'koa'; 4 | import http from 'http'; 5 | import { BaseEvent, ConnectEvent, QuackEvent } from '@elpatobot/events'; 6 | import ws from 'ws'; 7 | import TwitchClient from './twitchClient'; 8 | import { ChannelQuacksResponse, UserQuacksResponse } from '@elpatobot/responses'; 9 | import InMemoryCache from './InMemoryCache'; 10 | import twitchApi from './twitchApi'; 11 | import { env, settings } from './settings'; 12 | import { userRepository } from './repository/userRespository'; 13 | import koaBody from 'koa-body'; 14 | import { UserConfig } from '@elpatobot/entity'; 15 | 16 | const cache = new InMemoryCache(); 17 | const app = new Koa(); 18 | const connections: Record> = {}; 19 | 20 | app.use(cors({ 21 | origin: settings.corsDomain, 22 | })); 23 | 24 | app.use(koaBody({ jsonLimit: '1kb' })); 25 | 26 | const parseWebsocketMessage = (message: ws.MessageEvent) => { 27 | if (typeof message.data === 'string') return message.data; 28 | if (typeof message.data === 'object') { 29 | return new TextDecoder().decode(message.data as ArrayBuffer); 30 | } 31 | }; 32 | const server = http.createServer(app.callback()); 33 | const wss = new ws.Server({ server }); 34 | 35 | const onQuack:TwitchClient['_onQuackCallback'] = (userId, channel) => { 36 | cache.addOneQuack(userId, channel); 37 | const channelWithoutHash = channel.replace('#', ''); 38 | for (const connection of connections[channelWithoutHash] ?? []) { 39 | connection.send(JSON.stringify({ 40 | type: 'quack', 41 | content: null 42 | } satisfies QuackEvent)); 43 | } 44 | }; 45 | 46 | const getQuackRank = async ():Promise => { 47 | const users = await twitchApi.getUserProfileById(cache.topUsers.map((u) => u.userId)); 48 | const usersQuacks = cache.topUsers.map((user) => { 49 | const twitchUser = users.find(u => u.id === user.userId); 50 | if (!twitchUser) return; 51 | 52 | return { 53 | name: twitchUser.displayName, 54 | quacks: user.quackCount, 55 | }; 56 | }); 57 | 58 | let msg = 'Quack Rank:'; 59 | msg = usersQuacks 60 | .filter(u => u !== undefined) 61 | .sort((a,b) => b!.quacks - a!.quacks) 62 | .slice(0, 5) 63 | .map((u) => ` 🦆 ${u?.name} ha quackeado ${u?.quacks} `).join(''); 64 | return msg; 65 | 66 | }; 67 | 68 | const twitchClient = new TwitchClient(onQuack, getQuackRank); 69 | 70 | wss.on('connection', (socket) => { 71 | console.log('Someone connected'); 72 | 73 | socket.onmessage = (msg) => { 74 | const msgParsed = parseWebsocketMessage(msg); 75 | if (!msgParsed) return; 76 | const event = (JSON.parse(msgParsed) as BaseEvent); 77 | if (event.type === 'connect') { 78 | const { content } = (event as ConnectEvent); 79 | if (!content) return; 80 | const client = connections[content.channel]; 81 | if (!client) { 82 | connections[content.channel] = [socket]; 83 | } else if (!client.find((clientConnection) => clientConnection === socket)) { 84 | client.push(socket); 85 | } 86 | twitchClient.join(content.channel); 87 | } 88 | }; 89 | 90 | socket.on('close', async function() { 91 | console.log('Someone disconnected'); 92 | for (const key of Object.keys(connections)) { 93 | const sockets = connections[key]; 94 | connections[key] = sockets.filter(soc => soc !== socket); 95 | if (connections[key].length === 0) { 96 | setTimeout(() => { 97 | if (connections[key]?.length !== 0) return; 98 | delete connections[key]; 99 | console.log(`All connections for channel ${key} disconnected`); 100 | twitchClient.part(key); 101 | }, 60 * 1000); 102 | } 103 | } 104 | }); 105 | }); 106 | 107 | const appRouter = new Router(); 108 | 109 | appRouter.use('/', async (ctx, next) => { 110 | console.log(`[ ${ctx.method} ] - ${ctx.url}`); 111 | await next(); 112 | }); 113 | 114 | appRouter.get('/users/quacks', async (ctx) => { 115 | try { 116 | if (cache.topUsers.length === 0) { 117 | ctx.response.body = []; 118 | return; 119 | } 120 | const users = await twitchApi.getUserProfileById(cache.topUsers.map((u) => u.userId)); 121 | const respBody:Array = cache.topUsers.map((user) => { 122 | const twitchUser = users.find(u => u.id === user.userId); 123 | if (!twitchUser) return; 124 | 125 | return { 126 | name: twitchUser.displayName, 127 | quacks: user.quackCount, 128 | profileImg: twitchUser.profilePictureUrl, 129 | } as UserQuacksResponse; 130 | }); 131 | ctx.response.body = respBody.filter((i) => i !== undefined); 132 | } catch (e){ 133 | console.log(e); 134 | console.log('Error from user api : ', (e as any).data); 135 | } 136 | }); 137 | 138 | appRouter.get('/channels/quacks', async (ctx) => { 139 | try { 140 | if (cache.topChannels.length === 0) { 141 | ctx.response.body = []; 142 | return; 143 | } 144 | const channels = await twitchApi.getUserProfileByName(cache.topChannels.map((u) => u.userId)); 145 | const respBody:Array = cache.topChannels.map((channel) => { 146 | const twitchUser = channels.find(u => u.name === channel.userId.replace('#', '')); 147 | if (!twitchUser) return; 148 | 149 | return { 150 | name: twitchUser.displayName, 151 | quacks: channel.quackCount, 152 | profileImg: twitchUser.profilePictureUrl, 153 | description: twitchUser.description, 154 | } as ChannelQuacksResponse; 155 | }); 156 | ctx.response.body = respBody.filter((i) => i !== undefined); 157 | } catch (e) { 158 | console.log('Error from channel api : ', e); 159 | } 160 | }); 161 | 162 | appRouter.get('/user/config', async (ctx) => { 163 | const token = ctx.request.headers.authorization; 164 | if (typeof token !== 'string') throw new Error('token not found'); 165 | const { login } = await twitchApi.validateToken(token); 166 | const config = await userRepository.getUserConfig(login); 167 | ctx.response.body = config; 168 | }); 169 | 170 | appRouter.post('/user/config', async (ctx) => { 171 | const token = ctx.request.headers.authorization; 172 | if (typeof token !== 'string') throw new Error(); 173 | const { login } = await twitchApi.validateToken(token); 174 | const body = ctx.request.body; 175 | if (typeof body !== 'object' || body === null) throw new Error('missing body'); 176 | 177 | const { 178 | appearOnTheRanking, 179 | quackLimiterAmount, 180 | quackLimiterEnabled, 181 | } = (body as { 182 | appearOnTheRanking: unknown, 183 | quackLimiterAmount: unknown, 184 | quackLimiterEnabled: unknown 185 | }); 186 | 187 | if ( 188 | !(typeof appearOnTheRanking === 'boolean' && 189 | typeof quackLimiterAmount === 'number' && 190 | typeof quackLimiterEnabled === 'boolean') 191 | ) { 192 | ctx.res.statusCode = 400; 193 | return; 194 | } 195 | 196 | await userRepository.updateUserConfig({ 197 | appearOnTheRanking, 198 | quackLimiterAmount, 199 | quackLimiterEnabled, 200 | userId: login 201 | }); 202 | await twitchClient.updateConfigCache(login); 203 | 204 | ctx.res.statusCode = 200; 205 | }); 206 | 207 | app.use(appRouter.routes()); 208 | 209 | server.on('listening', () => { 210 | console.log(`Started listening on http://localhost:${settings.port} for ${env} environment`); 211 | }); 212 | 213 | server.listen(settings.port); -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/src/quackRestrictor/index.ts: -------------------------------------------------------------------------------- 1 | import { UserConfig } from '@elpatobot/entity'; 2 | 3 | interface UserActivity { 4 | lastActivity: Date, 5 | timeoutId: NodeJS.Timeout, 6 | warnings: number 7 | } 8 | 9 | class QuackRestrictor { 10 | private _userActivityMap: Record; 11 | 12 | constructor() { 13 | this._userActivityMap = {}; 14 | } 15 | 16 | private createUserToChanelKey = (user:string, channel:string) => (`${user}:${channel}`); 17 | 18 | public getWarnings = (userId: string, channel:string, config: UserConfig) => { 19 | const key = this.createUserToChanelKey(userId, channel); 20 | const userActivity = this._userActivityMap[key]; 21 | 22 | if (!userActivity) { 23 | this.updateActivity(key, config.quackLimiterAmount); 24 | return 0; 25 | } 26 | 27 | userActivity.warnings += 1; 28 | console.log(`quack not allowed for user ${userId} in channel ${channel} with ${userActivity.warnings} warnings`); 29 | return userActivity.warnings; 30 | }; 31 | 32 | private updateActivity = (key: string, delay: number) => { 33 | const timeoutId = setTimeout(() => this.clearActivity(key), delay * 1000); 34 | 35 | this._userActivityMap[key] = { 36 | lastActivity: new Date(), 37 | timeoutId, 38 | warnings: 0, 39 | }; 40 | }; 41 | 42 | public clearActivity = (key:string) => { 43 | delete this._userActivityMap[key]; 44 | }; 45 | } 46 | 47 | export default QuackRestrictor; -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/src/repository/dbClient/index.ts: -------------------------------------------------------------------------------- 1 | import SECRETS from '@elpatobot/secrets'; 2 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 3 | import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'; 4 | 5 | const env = process.env['env']; 6 | 7 | export type TableKey = 'UserQuacks' 8 | | 'ChannelQuacks' 9 | | 'TopUserQuacks' 10 | | 'TopChannelQuacks' 11 | | 'UserConfig' 12 | 13 | export const Tables: Record = { 14 | UserQuacks: `ElPatoBot-Aws-${env}-UserQuacks`, 15 | ChannelQuacks: `ElPatoBot-Aws-${env}-ChannelQuacks`, 16 | TopUserQuacks: `ElPatoBot-Aws-${env}-TopUserQuacks`, 17 | TopChannelQuacks: `ElPatoBot-Aws-${env}-TopChannelQuacks`, 18 | UserConfig: `ElPatoBot-Aws-${env}-UserConfig`, 19 | }; 20 | 21 | const region = 'eu-west-2'; 22 | const internalClient = new DynamoDBClient({ region, credentials: { 23 | accessKeyId: SECRETS.aws.key, 24 | secretAccessKey: SECRETS.aws.secretKey, 25 | } }); 26 | 27 | const marshallOptions = { 28 | convertEmptyValues: true, // false, by default. 29 | removeUndefinedValues: true, // false, by default. 30 | // Whether to convert typeof object to map attribute. 31 | convertClassInstanceToMap: false, // false, by default. 32 | }; 33 | 34 | const unmarshallOptions = { 35 | // Whether to return numbers as a string instead of converting them to native JavaScript numbers. 36 | wrapNumbers: false, // false, by default. 37 | }; 38 | 39 | const client = DynamoDBDocument.from(internalClient, { marshallOptions, unmarshallOptions }); 40 | 41 | export default client; 42 | -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/src/repository/quackRepository.ts: -------------------------------------------------------------------------------- 1 | import client, { TableKey, Tables } from './dbClient'; 2 | 3 | export interface QuackEntity { 4 | userId: string, 5 | quackCount: number, 6 | } 7 | 8 | const getQuacksForUser = async (userId: string) => { 9 | const userQuacks = await client.get({ 10 | TableName: Tables.UserQuacks, 11 | Key: { 12 | 'userId': userId 13 | } 14 | }); 15 | 16 | return (userQuacks.Item as QuackEntity)?.quackCount ?? 0; 17 | }; 18 | 19 | const getQuacksForChannel = async (userId: string) => { 20 | const userQuacks = await client.get({ 21 | TableName: Tables.ChannelQuacks, 22 | Key: { 23 | 'userId': userId 24 | } 25 | }); 26 | 27 | return (userQuacks.Item as QuackEntity)?.quackCount ?? 0; 28 | }; 29 | 30 | const setUserQuacks = async (userId: string, quacks: number) => { 31 | if (typeof userId !== 'string' || 32 | typeof quacks !== 'number' 33 | ) return; 34 | 35 | await client.put({ 36 | TableName: Tables.UserQuacks, 37 | Item: { 38 | 'userId': userId, 39 | 'quackCount': quacks, 40 | }, 41 | }); 42 | }; 43 | 44 | const setChannelQuacks = async (userId: string, quacks: number) => { 45 | if (typeof userId !== 'string' || 46 | typeof quacks !== 'number' 47 | ) return; 48 | 49 | await client.put({ 50 | TableName: Tables.ChannelQuacks, 51 | Item: { 52 | 'userId': userId, 53 | 'quackCount': quacks, 54 | }, 55 | }); 56 | }; 57 | 58 | const updateUserQuacks = async (userId: string, quacksToAdd: number) => { 59 | if (typeof userId !== 'string' || 60 | typeof quacksToAdd !== 'number' 61 | ) return; 62 | 63 | const userQuacks = await client.get({ 64 | TableName: Tables.UserQuacks, 65 | Key: { 66 | 'userId': userId 67 | } 68 | }); 69 | 70 | let newQuacks = quacksToAdd; 71 | if (userQuacks.Item) { 72 | newQuacks += userQuacks.Item['quackCount']; 73 | } 74 | 75 | await client.put({ 76 | TableName: Tables.UserQuacks, 77 | Item: { 78 | 'userId': userId, 79 | 'quackCount': newQuacks, 80 | }, 81 | }); 82 | }; 83 | 84 | const updateChannelQuacks = async (userId: string, quacksToAdd: number) => { 85 | if (typeof userId !== 'string' || 86 | typeof quacksToAdd !== 'number' 87 | ) return; 88 | 89 | const userQuacks = await client.get({ 90 | TableName: Tables.ChannelQuacks, 91 | Key: { 92 | 'userId': userId 93 | } 94 | }); 95 | 96 | let newQuacks = quacksToAdd; 97 | if (userQuacks.Item) { 98 | newQuacks += userQuacks.Item['quackCount']; 99 | } 100 | 101 | await client.put({ 102 | TableName: Tables.ChannelQuacks, 103 | Item: { 104 | 'userId': userId, 105 | 'quackCount': newQuacks, 106 | }, 107 | }); 108 | }; 109 | 110 | const getTopChannelQuacks = async () => { 111 | return ((await client 112 | .scan({ TableName: Tables.TopChannelQuacks, })).Items ?? []) as Array<{ 113 | userId: string 114 | }>; 115 | }; 116 | 117 | const getTopUserQuacks = async () => { 118 | return ((await client 119 | .scan({ TableName: Tables.TopUserQuacks, })).Items ?? []) as Array<{ 120 | userId: string 121 | }>; 122 | }; 123 | 124 | const updateTopUserQuacks = async (topUserQuacks: Array) => { 125 | await updateTopTable(topUserQuacks, 'TopUserQuacks'); 126 | }; 127 | 128 | const updateTopChannelQuacks = async (topChannelQuacks: Array) => { 129 | await updateTopTable(topChannelQuacks, 'TopChannelQuacks'); 130 | }; 131 | 132 | const updateTopTable = async (userIds: Array, tableKey: TableKey) => { 133 | const resp = await client.scan({ 134 | TableName: Tables[tableKey], 135 | }); 136 | 137 | const items = resp.Items as Array<{ userId: string }> | undefined; 138 | if (!items || items.length === 0) { 139 | for (const userId of userIds) { 140 | if (typeof userId !== 'string') return; 141 | await client.put({ 142 | TableName: Tables[tableKey], 143 | Item: { 144 | 'userId': userId 145 | } 146 | }); 147 | } 148 | return; 149 | } 150 | 151 | const itemsToAdd = userIds 152 | .filter((user) => !(items.find((itemInDb) => itemInDb.userId === user))); 153 | const itemsToRemove = items 154 | .filter((itemInDb) => !(userIds.find((user) => itemInDb.userId === user))); 155 | 156 | for (const userId of itemsToAdd) { 157 | if (typeof userId !== 'string') return; 158 | await client.put({ 159 | TableName: Tables[tableKey], 160 | Item: { 161 | 'userId': userId 162 | } 163 | }); 164 | } 165 | 166 | for (const { userId } of itemsToRemove) { 167 | if (typeof userId !== 'string') return; 168 | 169 | await client.delete({ 170 | TableName: Tables[tableKey], 171 | Key: { 172 | 'userId': userId 173 | } 174 | }); 175 | } 176 | }; 177 | 178 | const quackRepository = { 179 | updateChannelQuacks, 180 | updateUserQuacks, 181 | getQuacksForChannel, 182 | getQuacksForUser, 183 | updateTopChannelQuacks, 184 | updateTopUserQuacks, 185 | getTopChannelQuacks, 186 | getTopUserQuacks, 187 | setUserQuacks, 188 | setChannelQuacks 189 | }; 190 | 191 | export default quackRepository; -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/src/repository/userRespository.ts: -------------------------------------------------------------------------------- 1 | import { UserConfig } from '@elpatobot/entity'; 2 | import dbClient, { Tables } from './dbClient'; 3 | 4 | const getUserConfig = async (userId:string) => { 5 | const result = await dbClient.get({ 6 | TableName: Tables.UserConfig, 7 | Key: { userId } 8 | }); 9 | if (result.Item) { 10 | return result.Item as UserConfig; 11 | } 12 | 13 | return { 14 | appearOnTheRanking: true, 15 | quackLimiterAmount: 5, 16 | quackLimiterEnabled: true, 17 | userId 18 | } satisfies UserConfig; 19 | }; 20 | 21 | const updateUserConfig = async (userConfig: UserConfig) => { 22 | await dbClient.put({ 23 | TableName: Tables.UserConfig, 24 | Item: userConfig 25 | }); 26 | }; 27 | 28 | export const userRepository = { 29 | getUserConfig, 30 | updateUserConfig 31 | }; -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/src/settings.ts: -------------------------------------------------------------------------------- 1 | export const env = process.env['env'] as 'dev' | 'prd'; 2 | 3 | interface Settings { 4 | port: number, 5 | corsDomain: string, 6 | delayBetweenUserQuacksInSeconds: number, 7 | } 8 | 9 | const allSettings:Record<'dev' | 'prd', Settings> = { 10 | dev: { 11 | port: 8084, 12 | corsDomain: '*', 13 | delayBetweenUserQuacksInSeconds: 2, 14 | }, 15 | prd: { 16 | port: 8080, 17 | corsDomain: 'https://elpatobot.com', 18 | delayBetweenUserQuacksInSeconds: 5 19 | } 20 | }; 21 | 22 | export const settings = allSettings[env]; -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/src/twitchApi/index.ts: -------------------------------------------------------------------------------- 1 | import SECRETS from '@elpatobot/secrets'; 2 | import { ApiClient } from '@twurple/api'; 3 | import { AppTokenAuthProvider } from '@twurple/auth'; 4 | 5 | const createClient = () => { 6 | const auth = new AppTokenAuthProvider(SECRETS.twitch.clientId, 7 | SECRETS.twitch.extensionSecret); 8 | return new ApiClient({ 9 | authProvider: auth 10 | }); 11 | }; 12 | 13 | const getUserProfileById = async (userId: Array) => { 14 | const client = createClient(); 15 | return await client.users.getUsersByIds(userId); 16 | }; 17 | 18 | const getUserProfileByName = async (userNames: Array) => { 19 | const client = createClient(); 20 | return await client.users.getUsersByNames(userNames.map(name => name.replace('#', ''))); 21 | }; 22 | 23 | const validateToken = async (token: string) => { 24 | const headers = new Headers(); 25 | headers.append('Authorization', `OAuth ${token}`); 26 | const resp = await fetch('https://id.twitch.tv/oauth2/validate', { 27 | headers 28 | }); 29 | const values = await resp.json(); 30 | const clientId = values['client_id']; 31 | const userId = values['user_id']; 32 | const login = values['login']; 33 | if (typeof clientId !== 'string' || typeof userId !== 'string' || typeof login !== 'string') { 34 | throw new Error('Invalid token'); 35 | } 36 | if (clientId !== SECRETS.twitch.clientId) { 37 | throw new Error('Incorrect token'); 38 | } 39 | 40 | return { 41 | userId, 42 | clientId, 43 | login 44 | }; 45 | }; 46 | 47 | const twitchApi = { 48 | getUserProfileById, 49 | getUserProfileByName, 50 | validateToken 51 | }; 52 | 53 | export default twitchApi; -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/src/twitchClient/index.ts: -------------------------------------------------------------------------------- 1 | import SECRETS, { TokenCache } from '@elpatobot/secrets'; 2 | import QuackRestrictor from '../quackRestrictor'; 3 | import { ChatClient } from '@twurple/chat'; 4 | import { RefreshingAuthProvider } from '@twurple/auth'; 5 | import { UserConfig } from '@elpatobot/entity'; 6 | import { userRepository } from '../repository/userRespository'; 7 | 8 | interface TwitchPrivateMessage { 9 | id: string 10 | userInfo: { 11 | userId: string 12 | } 13 | } 14 | 15 | class TwitchClient { 16 | private _twitchClient: ChatClient; 17 | private _quackRestrictor: QuackRestrictor; 18 | private _tokenCache: TokenCache; 19 | private _onQuackCallback: (userId: string, channel: string) => void; 20 | private _getQuackRank: () => Promise; 21 | private _authProvider: RefreshingAuthProvider; 22 | private _configCache: Record; 23 | 24 | constructor(onQuack: TwitchClient['_onQuackCallback'], getQuackRank: TwitchClient['_getQuackRank']) { 25 | this._configCache = {}; 26 | this._getQuackRank = getQuackRank; 27 | this._onQuackCallback = onQuack; 28 | this._quackRestrictor = new QuackRestrictor(); 29 | this._tokenCache = new TokenCache(SECRETS.twitch.token, SECRETS.twitch.refreshToken); 30 | this._authProvider = new RefreshingAuthProvider({ 31 | clientId: SECRETS.twitch.clientId, 32 | clientSecret: SECRETS.twitch.extensionSecret, 33 | onRefresh: (_, newToken) => { 34 | this._tokenCache.writeToken({ 35 | accessToken: newToken.accessToken, 36 | expiresIn: newToken.expiresIn ?? 0, 37 | obtainedAt: newToken.obtainmentTimestamp, 38 | refreshToken: newToken.refreshToken, 39 | }); 40 | } 41 | }); 42 | 43 | // non awaited promise 44 | this._authProvider.addUserForToken({ 45 | expiresIn: this._tokenCache.current.expiresIn, 46 | obtainmentTimestamp: this._tokenCache.current.obtainedAt, 47 | refreshToken: this._tokenCache.current.refreshToken, 48 | accessToken: this._tokenCache.current.accessToken, 49 | scope: ['chat:read', 'chat:edit'], 50 | }, ['chat']).then(() => { 51 | this._twitchClient.connect(); 52 | }); 53 | this._twitchClient = new ChatClient({ authProvider: this._authProvider }); 54 | this._twitchClient.onAuthenticationFailure(() => { 55 | console.error('unable to authenticate with twitch chat'); 56 | }); 57 | this._twitchClient.onJoinFailure((chat, reason) => { 58 | console.error(`Unable to join channel:${chat} reason: ${reason}`); 59 | }); 60 | this._twitchClient.onDisconnect((manually) => { 61 | if (manually) { 62 | console.log(`left channel, connections: ${this._twitchClient.currentChannels}`); 63 | } 64 | }); 65 | this._twitchClient.onMessage(this.onMessage); 66 | this._twitchClient.onJoinFailure(console.error); 67 | } 68 | 69 | public updateConfigCache = async (channel: string) => { 70 | if (!this._configCache[channel]) return; 71 | const config = await userRepository.getUserConfig(channel); 72 | this._configCache[channel] = config; 73 | }; 74 | 75 | public join = async (channel: string) => { 76 | try { 77 | console.log(`Attempting to join channel ${channel}`); 78 | await this._twitchClient.join(channel); 79 | if (this._configCache[channel]) return; 80 | const config = await userRepository.getUserConfig(channel); 81 | this._configCache[channel] = config; 82 | } catch { 83 | console.log(`Unable to connect to ${channel}, could be incorrect name`); 84 | } 85 | }; 86 | 87 | private onQuackRank = async (channel: string) => { 88 | this._twitchClient.say(channel, await this._getQuackRank()); 89 | }; 90 | 91 | private onQuack = async (channel: string, user: string, msgData: TwitchPrivateMessage) => { 92 | const userId = msgData.userInfo.userId; 93 | const channelWithoutHash = channel.replace('#', ''); 94 | 95 | let config = this._configCache[channelWithoutHash]; 96 | if (!config) { 97 | config = await userRepository.getUserConfig(channelWithoutHash); 98 | } 99 | 100 | if (config.quackLimiterEnabled) { 101 | const warningCount = this._quackRestrictor.getWarnings(userId, channel, config); 102 | if (warningCount === 1) { 103 | await this._twitchClient.say(channel, `tranquilo @${user}! estas quackeando muy rapido, limita tus quacks a un quack cada ${config.quackLimiterAmount} segundos`); 104 | } 105 | 106 | if (warningCount > 0) return; 107 | } 108 | 109 | this._onQuackCallback(userId, channel); 110 | }; 111 | 112 | private onMessage = async (channel: string, user: string, message: string, msgData: TwitchPrivateMessage) => { 113 | if (message.toLowerCase().trim() === '!quackrank') { 114 | await this.onQuackRank(channel); 115 | } 116 | 117 | if (message 118 | .toLowerCase() 119 | .trim() 120 | .replace(' ', '') 121 | .includes('*quack*')){ 122 | await this.onQuack(channel, user, msgData); 123 | } 124 | }; 125 | 126 | public getConnections = () => this._twitchClient.currentChannels; 127 | 128 | public part = (channel: string) => { 129 | console.log(`Disconnecting from channel: ${channel}`); 130 | this._twitchClient.part(channel); 131 | }; 132 | } 133 | 134 | export default TwitchClient; -------------------------------------------------------------------------------- /apps/ElPatoBot.Server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@elpatobot/ts/base.json", 3 | "include": ["**/*.ts", "**/*.tsx"], 4 | "exclude": ["node_modules"] 5 | } -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:react/jsx-runtime" 11 | ], 12 | "overrides": [ 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": "latest", 17 | "sourceType": "module" 18 | }, 19 | "plugins": [ 20 | "react", 21 | "@typescript-eslint" 22 | ], 23 | "rules": { 24 | "indent": [ 25 | "error", 26 | 4 27 | ], 28 | "quotes": [ 29 | "error", 30 | "single" 31 | ], 32 | "semi": [ 33 | "error", 34 | "always" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elpatobot/ui", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@elpatobot/entity": "*", 6 | "@elpatobot/responses": "*", 7 | "@elpatobot/ts": "*", 8 | "@fortawesome/fontawesome-svg-core": "^6.2.0", 9 | "@fortawesome/free-regular-svg-icons": "^6.2.0", 10 | "@fortawesome/free-solid-svg-icons": "^6.2.0", 11 | "@fortawesome/react-fontawesome": "^0.2.0", 12 | "@types/node": "^16.7.13", 13 | "@types/react": "^18.0.0", 14 | "@types/react-dom": "^18.0.0", 15 | "@types/styled-components": "^5.1.26", 16 | "@types/tmi.js": "^1.8.2", 17 | "axios": "^1.2.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-router-dom": "^6.4.2", 21 | "react-scripts": "5.0.1", 22 | "react-tsparticles": "^2.7.1", 23 | "styled-components": "^5.3.6", 24 | "tmi.js": "^1.8.5", 25 | "tsparticles": "^2.7.1" 26 | }, 27 | "scripts": { 28 | "start:ui": "react-scripts start", 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject", 33 | "clean": "rimraf build node_modules" 34 | }, 35 | "eslintConfig": { 36 | "extends": [ 37 | "react-app", 38 | "react-app/jest" 39 | ] 40 | }, 41 | "browserslist": { 42 | "production": [ 43 | ">0.2%", 44 | "not dead", 45 | "not op_mini all" 46 | ], 47 | "development": [ 48 | "last 1 chrome version", 49 | "last 1 firefox version", 50 | "last 1 safari version" 51 | ] 52 | }, 53 | "devDependencies": { 54 | "@typescript-eslint/eslint-plugin": "^5.59.2", 55 | "@typescript-eslint/parser": "^5.59.2", 56 | "eslint": "^8.39.0", 57 | "eslint-plugin-react": "^7.32.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/public/audio/quack-1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatitoDev/ElPatoBot/7dc893ff4f8d1c0aa5bce4c95cc7594b0259705c/apps/ElPatoBot.UI/public/audio/quack-1.wav -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/public/img/DuckMouthClose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatitoDev/ElPatoBot/7dc893ff4f8d1c0aa5bce4c95cc7594b0259705c/apps/ElPatoBot.UI/public/img/DuckMouthClose.png -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/public/img/DuckMouthOpen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatitoDev/ElPatoBot/7dc893ff4f8d1c0aa5bce4c95cc7594b0259705c/apps/ElPatoBot.UI/public/img/DuckMouthOpen.png -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/public/img/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatitoDev/ElPatoBot/7dc893ff4f8d1c0aa5bce4c95cc7594b0259705c/apps/ElPatoBot.UI/public/img/bg.png -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/public/img/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/public/img/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | El Pato Bot 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 2 | import BotPage from '../components/pages/BotPage'; 3 | import HomePage from '../components/pages/HomePage'; 4 | 5 | const App = () => ( 6 | 7 | 8 | } /> 9 | } /> 10 | 11 | 12 | ); 13 | 14 | export default App; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/atoms/InputText/index.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes } from 'react'; 2 | import * as S from './styles'; 3 | 4 | export type InputTextProps = InputHTMLAttributes 5 | 6 | const InputText = ({children, ...props}: InputTextProps) => ( 7 | 8 | {children} 9 | 10 | ); 11 | 12 | export default InputText; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/atoms/InputText/styles.ts: -------------------------------------------------------------------------------- 1 | import Styled from 'styled-components'; 2 | 3 | export const Input = Styled.input` 4 | background-color: transparent; 5 | color: ${({theme}) => theme.colors.black}; 6 | border: solid 3px ${({theme}) => theme.colors.black}; 7 | padding: 1em 1.5em; 8 | font-size: 1.5em; 9 | border-radius: 3px; 10 | `; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/atoms/Link/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Typography from '../Typography'; 3 | 4 | const Link = styled.a` 5 | text-decoration: none; 6 | color: ${({theme}) => theme.colors.white}; 7 | ${Typography} { 8 | color: ${({theme}) => theme.colors.white}; 9 | } 10 | :hover { 11 | color: ${({theme}) => theme.colors.orange}; 12 | ${Typography} { 13 | color: ${({theme}) => theme.colors.orange}; 14 | } 15 | } 16 | `; 17 | 18 | export default Link; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/atoms/Typography/index.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css, DefaultTheme } from 'styled-components'; 2 | 3 | interface TypographyProps { 4 | noWrap?: boolean, 5 | variant?: 'title' | 'body' | 'action' | 'description', 6 | color?: keyof DefaultTheme['colors'], 7 | } 8 | 9 | const Typography = styled.span` 10 | color: ${({theme, color = 'black'}) => theme.colors[color]}; 11 | 12 | ${({ noWrap }) => noWrap && css` 13 | white-space: nowrap; 14 | `} 15 | 16 | ${({variant}) => variant === 'title' && css` 17 | font-weight: bold; 18 | font-size: 3em; 19 | `} 20 | 21 | ${({variant}) => variant === 'body' && css` 22 | font-size: 1.8em; 23 | `} 24 | 25 | ${({variant}) => variant === 'action' && css` 26 | font-weight: bold; 27 | font-size: 1.8em; 28 | `} 29 | 30 | ${({variant}) => variant === 'description' && css` 31 | font-size: 1.2em; 32 | `} 33 | `; 34 | 35 | export default Typography; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/molecules/ClickToCopy/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import Typography from '../../atoms/Typography'; 3 | import * as S from './style'; 4 | 5 | export interface ClickToCopyProps { 6 | content: string, 7 | } 8 | 9 | const ClickToCopy = ({ content }:ClickToCopyProps) => { 10 | const [isShowing, setIsShowing] = useState(false); 11 | 12 | const onClick = () => { 13 | setIsShowing(true); 14 | setTimeout(() => { 15 | setIsShowing(false); 16 | }, 1000); 17 | navigator.clipboard.writeText(content); 18 | }; 19 | 20 | return ( 21 | <> 22 | {isShowing && ( 23 | 24 | Copiado al clipboard 25 | 26 | )} 27 | 30 | click to copy 34 | 35 | {content} 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default ClickToCopy; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/molecules/ClickToCopy/style.ts: -------------------------------------------------------------------------------- 1 | import Styled, { keyframes } from 'styled-components'; 2 | import Typography from '../../atoms/Typography'; 3 | 4 | export const Container = Styled.button` 5 | cursor: pointer; 6 | border: none; 7 | background-color: unset; 8 | margin: 0; 9 | display: flex; 10 | align-items: center; 11 | > *:first-child { 12 | margin-right: 0.5em; 13 | } 14 | :hover { 15 | ${Typography} { 16 | color: ${({theme}) => theme.colors.orange}; 17 | } 18 | } 19 | `; 20 | 21 | const slideIn = keyframes` 22 | from { 23 | transform: translateX(150%); 24 | } 25 | 26 | to { 27 | transform: translateX(0); 28 | } 29 | `; 30 | export const NotificationContainer = Styled.div` 31 | position: absolute; 32 | top: 15px; 33 | right: 15px; 34 | background-color: #383838; 35 | color: white; 36 | padding: 0.5em 1em; 37 | border-radius: 8px; 38 | font-size: 1.2em; 39 | font-weight: bold; 40 | animation: 0.8s ${slideIn}; 41 | `; 42 | 43 | -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/molecules/QuackCard/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from '../../atoms/Link'; 2 | import Typography from '../../atoms/Typography'; 3 | import * as S from './styles'; 4 | 5 | export interface QuackCardProps { 6 | rankPosition: number, 7 | profileUrl: string, 8 | name: string, 9 | description?: string, 10 | quacks: number, 11 | url?: string, 12 | } 13 | 14 | const QuackCard = ({ 15 | name, 16 | profileUrl, 17 | quacks, 18 | rankPosition, 19 | description, 20 | url 21 | }: QuackCardProps) => ( 22 | 23 | 24 | {rankPosition} 25 | 26 | {name} 27 | 28 | { url ? 29 | 30 | 31 | {name} 32 | 33 | 34 | : 35 | 36 | {name} 37 | 38 | } 39 | {description && ( 40 | 41 | {description} 42 | 43 | )} 44 | 45 | 46 | {quacks} 47 | {' '} 48 | Quacks 49 | 50 | 51 | ); 52 | 53 | export default QuackCard; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/molecules/QuackCard/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Card = styled.div<{rankPos: number}>` 4 | color: ${(prop) => prop.color}; 5 | background-color: ${({theme}) => theme.colors.black }; 6 | border-radius: 1em; 7 | padding: 1em; 8 | display: flex; 9 | align-items: center; 10 | text-align: left; 11 | > img { 12 | border-radius: 50%; 13 | margin-left: 1.5em; 14 | margin-right: 1em; 15 | width: 40px; 16 | } 17 | > :last-child { 18 | margin-left: auto; 19 | } 20 | opacity: ${({rankPos}) => 1 - (((rankPos - 3) * 0.15))}; 21 | `; 22 | 23 | export const TitleContainer = styled.div` 24 | display: flex; 25 | flex-direction: column; 26 | overflow: auto; 27 | max-width: 60%; 28 | > * { 29 | overflow: hidden; 30 | text-overflow: ellipsis; 31 | white-space: nowrap; 32 | } 33 | `; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/organism/Leaderboard/index.tsx: -------------------------------------------------------------------------------- 1 | import * as S from './styles'; 2 | import { faExclamationTriangle, faSpinner, faUser, faVideoCamera, IconDefinition } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import axios from 'axios'; 5 | import { useEffect, useState } from 'react'; 6 | import { ChannelQuacksResponse, UserQuacksResponse } from '@elpatobot/responses'; 7 | import QuackCard from '../../molecules/QuackCard'; 8 | import Typography from '../../atoms/Typography'; 9 | import settings from '../../../settings'; 10 | 11 | interface State { 12 | userQuacks: Array | null, 13 | channelQuacks: Array | null, 14 | isLoading: boolean, 15 | error: boolean, 16 | } 17 | 18 | interface RankingType { 19 | id: number, 20 | icon: IconDefinition, 21 | name: string, 22 | } 23 | 24 | const rankingTypes: Array = [ 25 | { 26 | id: 0, 27 | icon: faUser, 28 | name: 'Quacks Por Usuario', 29 | }, 30 | { 31 | id: 1, 32 | icon: faVideoCamera, 33 | name: 'Quacks Por Canal', 34 | }, 35 | ]; 36 | const Leaderboard = () => { 37 | const [{ 38 | channelQuacks, 39 | error, 40 | isLoading, 41 | userQuacks 42 | }, setState] = useState({ 43 | isLoading: false, 44 | channelQuacks: null, 45 | error: false, 46 | userQuacks: null, 47 | }); 48 | 49 | const [selectedRankType, setSelectedRankType] = useState(0); 50 | 51 | useEffect(() => { 52 | (async () => { 53 | try { 54 | setState({ 55 | isLoading: true, 56 | channelQuacks: null, 57 | error: false, 58 | userQuacks: null, 59 | }); 60 | const userQuackResp = await axios.get(settings.serverUrl + 'users/quacks'); 61 | const channelQuackResp = await axios.get(settings.serverUrl + 'channels/quacks'); 62 | setState({ 63 | isLoading: false, 64 | channelQuacks: channelQuackResp.data, 65 | userQuacks: userQuackResp.data, 66 | error: false, 67 | }); 68 | } catch { 69 | setState({ 70 | isLoading: false, 71 | channelQuacks: null, 72 | userQuacks: null, 73 | error: true, 74 | }); 75 | } 76 | })(); 77 | }, []); 78 | 79 | if (error) { 80 | return ( 81 | 82 | 83 | {' '} 84 | Ranking exploto! Demasiados *quack*s 85 | 86 | ); 87 | } 88 | 89 | if (isLoading) { 90 | return ( 91 | 92 | 93 | 94 | ); 95 | } 96 | 97 | return ( 98 | 99 | 100 | {rankingTypes.map((rankType) => ( 101 | { 105 | setSelectedRankType(rankType.id); 106 | }}> 107 | 108 | {rankType.name} 109 | 110 | ))} 111 | 112 | 113 | {userQuacks && selectedRankType === 0 && userQuacks 114 | .sort((a, b) => b.quacks - a.quacks) 115 | .slice(0, 9) 116 | .map((user, index) => ( 117 | 124 | ))} 125 | 126 | {channelQuacks && selectedRankType === 1 && channelQuacks 127 | .sort((a, b) => b.quacks - a.quacks) 128 | .slice(0, 9) 129 | .map((channel, index) => ( 130 | 139 | ))} 140 | 141 | ); 142 | }; 143 | 144 | export default Leaderboard; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/organism/Leaderboard/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | width: 100%; 5 | > * { 6 | margin-bottom: 1em; 7 | } 8 | `; 9 | 10 | export const RankTypesMenu = styled.div` 11 | display: flex; 12 | width: 100%; 13 | > *:not(:last-child){ 14 | margin-right: 0.8em; 15 | } 16 | `; 17 | 18 | export const MenuButton = styled.button<{isSelected: boolean}>` 19 | 20 | ${({ isSelected }) => !isSelected && css` 21 | opacity: 0.5; 22 | :hover { 23 | color: ${({theme}) => theme.colors.orange}; 24 | opacity: 0.8; 25 | } 26 | `} 27 | 28 | > *:first-child { 29 | margin-right: 0.2em; 30 | } 31 | 32 | color: ${({theme}) => theme.colors.black}; 33 | font-weight: bold; 34 | border: none; 35 | background-color: initial; 36 | padding: 0; 37 | font-size: 1.2em; 38 | cursor: pointer; 39 | `; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/organism/UserConfiguration/index.tsx: -------------------------------------------------------------------------------- 1 | import { UserConfig } from '@elpatobot/entity'; 2 | import axios from 'axios'; 3 | import settings from '../../../settings'; 4 | import { useEffect, useState } from 'react'; 5 | import Typography from '../../atoms/Typography'; 6 | import * as S from './styles'; 7 | 8 | export interface UserConfigurationProps { 9 | token: string 10 | } 11 | 12 | const UserConfiguration = ({ token }: UserConfigurationProps) => { 13 | const [config, setConfig] = useState(null); 14 | 15 | useEffect(() => { 16 | (async () => { 17 | const configResp = await axios.get(settings.serverUrl + 'user/config', { 18 | headers: { 19 | 'Authorization': token 20 | } 21 | }); 22 | setConfig(configResp.data); 23 | })(); 24 | }, [token]); 25 | 26 | 27 | useEffect(() => { 28 | (async () => { 29 | if (config === null) return; 30 | await axios.post(settings.serverUrl + 'user/config', config, { 31 | headers: { 32 | 'Authorization': token 33 | } 34 | }); 35 | })(); 36 | }, [config]); 37 | 38 | const onAppearOnRankingChange = (value: boolean) => { 39 | setConfig((prev) => { 40 | if (!prev) return prev; 41 | return ({ 42 | ...prev, 43 | appearOnTheRanking: value 44 | }); 45 | }); 46 | }; 47 | 48 | const onLimiterChanged = (value: boolean) => { 49 | setConfig((prev) => { 50 | if (!prev) return prev; 51 | return ({ 52 | ...prev, 53 | quackLimiterEnabled: value 54 | }); 55 | }); 56 | }; 57 | 58 | const onQuackLimiterAmount = (value: string) => { 59 | setConfig((prev) => { 60 | if (!prev) return prev; 61 | const parsedValue = parseInt(value); 62 | if (isNaN(parsedValue)) return prev; 63 | return { 64 | ...prev, 65 | quackLimiterAmount: parsedValue 66 | }; 67 | }); 68 | }; 69 | 70 | if (config) return ( 71 | 72 | {/* 73 |
74 | { 75 | onAppearOnRankingChange(!config.appearOnTheRanking); 76 | }} 77 | /> 78 | 83 |
84 | */} 85 |
86 | 87 | { 88 | onLimiterChanged(!config.quackLimiterEnabled); 89 | }} /> 90 | 98 | 99 |
100 |
101 | ); 102 | 103 | return ( 104 | <>Loading... 105 | ); 106 | }; 107 | 108 | export default UserConfiguration; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/organism/UserConfiguration/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | text-align: left; 5 | display: flex; 6 | flex-direction: column; 7 | input { 8 | width: 2em; 9 | height: 2em; 10 | vertical-align: middle; 11 | text-align: center; 12 | margin: 0.5em; 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/pages/BotPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { BaseEvent, ConnectEvent } from '@elpatobot/events'; 4 | import settings from '../../../settings'; 5 | 6 | const parseWebsocketMessage = (message: MessageEvent) => { 7 | if (typeof message.data === 'string') return message.data; 8 | if (typeof message.data === 'object') { 9 | return new TextDecoder().decode(message.data as ArrayBuffer); 10 | } 11 | }; 12 | 13 | const BotPage = () => { 14 | const { userName } = useParams<{userName: string}>(); 15 | const [wsClient, setWsClient] = useState(new WebSocket(settings.websocketUrl)); 16 | 17 | useEffect(() => { 18 | wsClient.onopen = () => { 19 | if (!userName) return; 20 | 21 | wsClient.send(JSON.stringify({ 22 | type: 'connect', 23 | content: { 24 | channel: userName.toLowerCase(), 25 | } 26 | } as ConnectEvent)); 27 | }; 28 | wsClient.onmessage = (e) => { 29 | const resp = parseWebsocketMessage(e); 30 | if (!resp) return; 31 | const msg = JSON.parse(resp) as BaseEvent; 32 | if (msg.type === 'quack'){ 33 | const audio = new Audio('/audio/quack-1.wav'); 34 | audio.loop = false; 35 | audio.play(); 36 | } 37 | }; 38 | 39 | wsClient.onclose = () => { 40 | console.log('lost connection to ws server, reconnecting...'); 41 | setWsClient(new WebSocket(settings.websocketUrl)); 42 | }; 43 | 44 | wsClient.onerror = (e) => { 45 | console.log('Error on connecting to ws ', e); 46 | }; 47 | }, [wsClient]); 48 | 49 | return <>; 50 | }; 51 | 52 | export default BotPage; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/pages/HomePage/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import Particles from 'react-tsparticles'; 3 | import InputText from '../../atoms/InputText'; 4 | import Typography from '../../atoms/Typography'; 5 | import ClickToCopy from '../../molecules/ClickToCopy'; 6 | import Leaderboard from '../../organism/Leaderboard'; 7 | import type { Engine } from 'tsparticles-engine'; 8 | import * as S from './styles'; 9 | import { loadFull } from 'tsparticles'; 10 | import settings from '../../../settings'; 11 | import { particleOptions } from './particleOptions'; 12 | import UserConfiguration from '../../organism/UserConfiguration'; 13 | 14 | const HomePage = () => { 15 | const [userName, setUserName] = useState(''); 16 | const [token, setToken] = useState(null); 17 | 18 | useEffect(() => { 19 | const onHashChange = () => { 20 | const keyValuePairs = location.hash.replace('#', '').split('&'); 21 | for (const keyValuePair of keyValuePairs) { 22 | const [key, value] = keyValuePair.split('='); 23 | console.log(key, value); 24 | if (key === 'access_token' && value) { 25 | setToken(value); 26 | location.hash = ''; 27 | return; 28 | } 29 | } 30 | }; 31 | onHashChange(); 32 | }, []); 33 | 34 | 35 | 36 | const onLogOut = () => { 37 | setToken(null); 38 | }; 39 | 40 | const particlesInit = useCallback(async (engine: Engine) => { 41 | // you can initialize the tsParticles instance (engine) here, adding custom shapes or presets 42 | // this loads the tsparticles package bundle, it's the easiest method for getting everything ready 43 | // starting from v2 you can add only the features you need reducing the bundle size 44 | await loadFull(engine); 45 | }, []); 46 | 47 | return ( 48 | 49 | 55 | 56 | 57 | El Pato Bot 58 |
59 |
60 | 61 | Sube el nivel de tu stream con el pato que hace  62 | 63 | 64 | *quack* 65 | 66 |
67 |
68 | 69 | con el commando  70 | 71 | 72 | *quack* 73 | 74 |
75 |
76 | 77 | { setUserName(e.currentTarget.value); }} 80 | placeholder="Nombre de twitch..." 81 | /> 82 | 83 | 84 | 85 | 86 | 87 | Copia este link on OBS para empezar a quackear. 88 | 89 | 90 | 91 | {!token ? ( 92 | Login para configurar 93 | ) : ( 94 | <> 95 | Log out 96 | 97 | 98 | )} 99 | 100 | 101 | 102 |
103 |
104 | ); 105 | }; 106 | 107 | export default HomePage; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/pages/HomePage/particleOptions.ts: -------------------------------------------------------------------------------- 1 | import type { ISourceOptions } from 'tsparticles-engine'; 2 | 3 | export const particleOptions: ISourceOptions = { 4 | fullScreen: { 5 | enable: true, 6 | zIndex: -1 7 | }, 8 | background: { 9 | color: { 10 | value: '#FFCD6B', 11 | }, 12 | }, 13 | fpsLimit: 60, 14 | interactivity: { 15 | events: { 16 | onHover: { 17 | enable: true, 18 | mode: 'attract', 19 | }, 20 | resize: true, 21 | }, 22 | modes: { 23 | attract: { 24 | }, 25 | }, 26 | }, 27 | particles: { 28 | collisions: { 29 | enable: true, 30 | }, 31 | move: { 32 | enable: true, 33 | outModes: { 34 | default: 'bounce', 35 | }, 36 | random: true, 37 | speed: 1, 38 | straight: false, 39 | }, 40 | number: { 41 | density: { 42 | enable: true, 43 | area: 800, 44 | }, 45 | value: 100, 46 | }, 47 | shape: { 48 | type: 'image', 49 | images: [{ 50 | src: '/img/DuckMouthOpen.png', 51 | 52 | },{ 53 | src: '/img/DuckMouthClose.png', 54 | }] 55 | }, 56 | size: { 57 | value: 20, 58 | }, 59 | }, 60 | detectRetina: true, 61 | }; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/components/pages/HomePage/styles.ts: -------------------------------------------------------------------------------- 1 | import Styled, { css } from 'styled-components'; 2 | 3 | export const Page = Styled.div` 4 | display: flex; 5 | justify-content: center; 6 | height: 100vh; 7 | overflow: auto; 8 | align-items: baseline; 9 | `; 10 | 11 | export const Card = Styled.div` 12 | max-width: 800px; 13 | margin: 15em 20px 20px 20px; 14 | word-break: break-word; 15 | text-align: center; 16 | padding: 2em 4em; 17 | border-radius: 1em; 18 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); 19 | background-color: ${({theme}) => theme.colors.white}; 20 | display: flex; 21 | flex-direction: column; 22 | align-items: center; 23 | justify-content: space-between; 24 | > *:not(:first-child) { 25 | margin: 2em 0; 26 | } 27 | @media (max-width:515px) or (max-height: 500px) { 28 | max-height: 90%; 29 | max-width: 90%; 30 | overflow: auto; 31 | padding: 10px; 32 | font-size: 0.7rem; 33 | margin: 5px; 34 | } 35 | `; 36 | 37 | const sharedButtonStyles = css` 38 | margin-top: 2em; 39 | outline: solid 2px #f8d2ba; 40 | color: black; 41 | background: linear-gradient(89.76deg, #FFE5A0 0.14%, #FFC5A5 104.7%); 42 | padding: 0.7em 1em; 43 | border-radius: 3px; 44 | font-size: 1.3em; 45 | :hover { 46 | box-shadow: 0 0 4px 0px #8f7529; 47 | } 48 | `; 49 | 50 | export const Anchor = Styled.a` 51 | display: inline-block; 52 | text-decoration: none; 53 | ${sharedButtonStyles} 54 | `; 55 | 56 | export const Button = Styled.button` 57 | margin-bottom: 2em; 58 | cursor: pointer; 59 | border: none; 60 | ${sharedButtonStyles} 61 | `; 62 | 63 | export const CenterContainer = Styled.div` 64 | text-align: center; 65 | flex-direction: column; 66 | display: flex; 67 | align-items: center; 68 | `; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | overflow: hidden; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import { ThemeProvider } from 'styled-components'; 3 | import App from './App'; 4 | import './index.css'; 5 | import { theme } from './theme/theme'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | 11 | root.render( 12 | 13 | 14 | 15 | ); -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/settings.ts: -------------------------------------------------------------------------------- 1 | const env = process.env['NODE_ENV'] === 'production' ? 'prd' : 'dev'; 2 | 3 | if (env === 'dev') { 4 | console.log(`Using env ${env}`); 5 | } 6 | 7 | const allSettings:Record<'prd' | 'dev', { 8 | serverUrl: string, 9 | websocketUrl: string, 10 | loginUrl: string 11 | }> = { 12 | dev: { 13 | serverUrl: 'http://localhost:8084/', 14 | websocketUrl: 'ws://localhost:8084/', 15 | loginUrl: 'https://id.twitch.tv/oauth2/authorize?response_type=token&client_id=u4i00xkzdt2e5ti2c10p396uqlkkvw&redirect_uri=http://localhost:3000&scope=&state=c3ab8aa609ea11e793ae92361f002671' 16 | }, 17 | prd: { 18 | serverUrl: 'https://api.niv3kelpato.com/', 19 | websocketUrl: 'wss://api.niv3kelpato.com/', 20 | loginUrl: 'https://id.twitch.tv/oauth2/authorize?response_type=token&client_id=u4i00xkzdt2e5ti2c10p396uqlkkvw&redirect_uri=https://elpatobot.com&scope=&state=c3ab8aa609ea11e793ae92361f002671', 21 | } 22 | }; 23 | 24 | export default allSettings[env]; 25 | 26 | -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/theme.d.ts: -------------------------------------------------------------------------------- 1 | import 'styled-components'; 2 | 3 | declare module 'styled-components' { 4 | export interface DefaultTheme { 5 | colors: { 6 | white: string; 7 | black: string; 8 | gray: string; 9 | lightGray: string; 10 | orange: string; 11 | yellow: string; 12 | }; 13 | } 14 | } -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/src/theme/theme.ts: -------------------------------------------------------------------------------- 1 | import { DefaultTheme } from 'styled-components'; 2 | 3 | export const theme:DefaultTheme = { 4 | colors: { 5 | white: '#EAEAEA', 6 | lightGray: '#9E9E9E', 7 | orange: '#E0510C', 8 | black: '#222222', 9 | gray: '#4E4E4E', 10 | yellow: '#FFCD6B' 11 | } 12 | }; -------------------------------------------------------------------------------- /apps/ElPatoBot.UI/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@elpatobot/ts/nextjs.json", 3 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 4 | "exclude": ["node_modules"] 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elpatobot", 3 | "version": "1.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "apps/*", 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "build": "turbo run build", 11 | "start": "turbo run start", 12 | "start:dev": "turbo run start:dev", 13 | "start:prd": "turbo run start:prd", 14 | "start:ui": "turbo run start:ui", 15 | "clean": "rimraf node_modules" 16 | }, 17 | "devDependencies": {}, 18 | "engines": { 19 | "node": ">=14.0.0" 20 | }, 21 | "dependencies": { 22 | "eslint-plugin-react": "^7.31.11", 23 | "turbo": "latest" 24 | }, 25 | "packageManager": "yarn@1.22.19" 26 | } 27 | -------------------------------------------------------------------------------- /packages/entity/index.ts: -------------------------------------------------------------------------------- 1 | export interface UserConfig { 2 | userId: string, 3 | appearOnTheRanking: boolean, 4 | quackLimiterEnabled: boolean 5 | quackLimiterAmount: number 6 | } -------------------------------------------------------------------------------- /packages/entity/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elpatobot/entity", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@elpatobot/ts": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/events/index.ts: -------------------------------------------------------------------------------- 1 | export type EventType = "connect" | "quack" 2 | 3 | export interface BaseEvent { 4 | type: T 5 | content: C 6 | } 7 | 8 | export interface ConnectEvent extends BaseEvent<'connect', { 9 | channel: string 10 | }> {} 11 | 12 | export interface QuackEvent extends BaseEvent<'quack', null> {} -------------------------------------------------------------------------------- /packages/events/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elpatobot/events", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@elpatobot/ts": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/responses/index.ts: -------------------------------------------------------------------------------- 1 | export interface UserQuacksResponse { 2 | name: string, 3 | quacks: number, 4 | profileImg: string 5 | } 6 | 7 | export interface ChannelQuacksResponse { 8 | name: string, 9 | description: string, 10 | quacks: number, 11 | profileImg: string, 12 | channelUrl: string 13 | } -------------------------------------------------------------------------------- /packages/responses/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elpatobot/responses", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@elpatobot/ts": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/secrets/index.ts: -------------------------------------------------------------------------------- 1 | import secrets from './secrets'; 2 | export * from './tokenCache'; 3 | export default secrets; -------------------------------------------------------------------------------- /packages/secrets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elpatobot/secrets", 3 | "version": "1.0.0", 4 | "main": "./index.ts", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@elpatobot/ts": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/secrets/tokenCache.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const FILE_CACHE_PATH = '/elpatobot_token.json'; 4 | 5 | interface Token { 6 | accessToken: string, 7 | refreshToken: string | null, 8 | expiresIn: number, 9 | obtainedAt: number, 10 | } 11 | 12 | export class TokenCache { 13 | 14 | public current: Token; 15 | 16 | constructor(initialToken?:string, initialRefreshToken?: string) { 17 | console.log('using initial token'); 18 | this.current = { 19 | accessToken: initialToken ?? '', 20 | refreshToken: initialRefreshToken ?? '', 21 | expiresIn: 0, 22 | obtainedAt: 0 23 | } 24 | 25 | if (!fs.existsSync(FILE_CACHE_PATH)) return; 26 | console.log('using cached token'); 27 | this.readToken(); 28 | } 29 | 30 | public readToken() { 31 | const file = fs.readFileSync(FILE_CACHE_PATH, 'utf-8'); 32 | this.current = JSON.parse(file); 33 | return this.current; 34 | } 35 | 36 | public writeToken(data: Token) { 37 | this.current = data; 38 | fs.writeFileSync(FILE_CACHE_PATH, JSON.stringify(this.current), 'utf-8'); 39 | } 40 | } -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/tsconfig/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "target": "es5", 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve" 19 | }, 20 | "include": ["src", "next-env.d.ts"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elpatobot/ts", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "base.json", 7 | "nextjs.json", 8 | "react-library.json" 9 | ], 10 | "dependencies": { 11 | "typescript": "^4.4.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "start:dev": { 5 | "cache": false 6 | }, 7 | "start:ui": { 8 | "cache": false 9 | }, 10 | "start:prd": { 11 | "cache": false 12 | }, 13 | "start": { 14 | "cache": false 15 | }, 16 | "build": { 17 | "dependsOn": ["^build"], 18 | "outputs": ["dist/**", ".next/**", "build/**"] 19 | }, 20 | "deploy:dev": { 21 | "cache": false 22 | }, 23 | "deploy:prd": { 24 | "cache": false 25 | }, 26 | "lint": { 27 | "outputs": [] 28 | }, 29 | "dev": { 30 | "cache": false 31 | }, 32 | "clean": { 33 | "cache": false 34 | } 35 | } 36 | } 37 | --------------------------------------------------------------------------------