diff --git a/app/Controllers/Http/ProfilesController.ts b/app/Controllers/Http/ProfilesController.ts index 193d888..4896560 100644 --- a/app/Controllers/Http/ProfilesController.ts +++ b/app/Controllers/Http/ProfilesController.ts @@ -268,12 +268,8 @@ export default class ProfilesController { return response.notFound({ error: this.Error.notFound }) } - const serializedProfile = profile.serialize() - - serializedProfile.mismatches.push(match.id) - // We don't want to attempt to match with this user again in the future, so add them to the mismatches list. - profile.mismatches = serializedProfile.mismatches + profile.mismatches.push(match.id) await profile.save() diff --git a/app/Services/Handler.ts b/app/Services/Handler.ts new file mode 100644 index 0000000..15a7aa0 --- /dev/null +++ b/app/Services/Handler.ts @@ -0,0 +1,289 @@ +import Logger from '@ioc:Adonis/Core/Logger' +import Database from '@ioc:Adonis/Lucid/Database' +import Profile from 'App/Models/Profile' +import User from 'App/Models/User' +import { BASE, MessageType, NameAndId, ROLLMATCH, UNMATCH, ADDEMOTES } from 'befriendlier-shared' +import { DateTime } from 'luxon' + +class Handler { + /** + * MAKE SURE TO CATCH ERRORS. + */ + public async rollMatch ({ userTwitch, channelTwitch }: ROLLMATCH) { + const { chatOwnerUser, profile } = await this.findProfileOrCreateByChatOwner(userTwitch, channelTwitch) + + if (profile.rolls.length > 0) { + // Return match + const rau = await this.rollUntilAvailableUser(profile.rolls) + + if (rau instanceof Error) { + throw this.error(MessageType.ERROR, userTwitch, channelTwitch, + 'looks like you\'re not lucky today, rubber ducky 🦆 Try swiping again in a bit.') + } else { + const { + rolls, + user: matchUser, + profile: matchProfile, + } = rau + + // We don't need to save the array if it's not been edited. + if (rolls instanceof Array && rolls.length !== profile.rolls.length) { + profile.rolls = rolls as number[] + await profile.save() + } + + return { user: matchUser as User, profile: matchProfile as Profile } + } + } + + if (profile.nextRolls.diffNow('hours').hours >= 0) { + throw this.error(MessageType.ERROR, userTwitch, channelTwitch, `you are on a cooldown. Please try again ${String(profile.nextRolls.toRelative())}.`) + } + + await profile.preload('matches') + + /** + * Find matches in the same chat, + * filter away deleted users, + * filter away own profile, mismatches and current matches. + */ + const profiles = await Profile.query() + .where('chatUserId', chatOwnerUser.id) + .where('enabled', true) + .whereNotIn('id', [ + profile.id, + ...profile.mismatches, + ...profile.matches.map(match => match.id), + ]) + + // Ratelimit user's rolls to every 5 hours. + profile.nextRolls = DateTime.fromJSDate(new Date()).plus({ hours: 5 }) + + if (profiles.length === 0) { + await profile.save() + throw this.error(MessageType.TAKEABREAK, userTwitch, channelTwitch, + `looks like you're not lucky today, rubber ducky 🦆 Try swiping again in ${String(profile.nextRolls.toRelative())}.`) + } + + // Shuffle the array! + this.durstenfeldShuffle(profiles) + + profile.rolls = profiles.map(profile => profile.id).slice(0, 10) + + await profile.save() + + const matchedUser = await this.findUserByProfile(profiles[0]) + + return { user: matchedUser, profile: profiles[0] } + } + + /** + * MAKE SURE TO CATCH ERRORS. + */ + public async match ({ userTwitch, channelTwitch }: BASE) { + const { user, profile } = await this.findProfileOrCreateByChatOwner(userTwitch, channelTwitch) + + const profileId = profile.rolls.shift() + + const matchProfile = await Profile.find(profileId) + + if (matchProfile === null) { + // Shouldn't hit here, maybe user deleted as soon as they tried to match. + Logger.error(`Tried to match with an unknown profile. profileId: ${String(profileId)}`) + throw this.error(MessageType.ERROR, userTwitch, channelTwitch, 'user does not exist.') + } + + // Add match for this profile. + await profile.related('matches').attach({ + [matchProfile.id]: { + user_id: profile.userId, + match_user_id: matchProfile.userId, + }, + }) + + // Check if matched profile also has this user as a match, if so, announce match to both users. + const hasMatched = await Database.query().from('matches_lists').where({ + profile_id: matchProfile.id, + match_user_id: user.id, + }).first() + + const matchUser = await this.findUserByProfile(matchProfile) + + if (matchUser === null) { + // Shouldn't hit here, maybe user deleted as soon as they tried to match. + Logger.error(`Tried to match with an unknown user. userId:${matchProfile.userId}, profileId: ${String(profileId)}`) + throw this.error(MessageType.ERROR, userTwitch, channelTwitch, 'user does not exist.') + } + + await profile.save() + + if (hasMatched !== null) { + return { attempt: MessageType.SUCCESS, user, matchUser } + } else { + return { attempt: MessageType.MATCH } + } + } + + /** + * MAKE SURE TO CATCH ERRORS. + */ + public async unmatch ({ userTwitch, matchUserTwitch, channelTwitch }: UNMATCH) { + const { profile, chatOwnerUser } = await this.findProfileOrCreateByChatOwner(userTwitch, channelTwitch) + + const matchUser = await User.findBy('name', matchUserTwitch.name) + + if (matchUser === null) { + // Shouldn't hit here, maybe user deleted as soon as they tried to unmatch or they simply do not exist. + Logger.error(`Tried to unmatch with an unknown user. matchUserTwitch.name:${String(matchUserTwitch.name)}, profile.id: ${String(profile.id)}`) + throw this.error(MessageType.ERROR, userTwitch, channelTwitch, 'user does not exist.') + } + + const matchProfile = await matchUser.related('profile').query().where('chatUserId', chatOwnerUser.id).first() + + if (matchProfile === null) { + /** + * Shouldn't hit here, maybe user deleted their profile + * as soon as they tried to unmatch or it simply does not exist. + */ + Logger.error(`Tried to unmatch with an unknown profile. matchUserTwitch.name:${String(matchUserTwitch.name)}, profile.id: ${String(profile.id)}`) + throw this.error(MessageType.ERROR, userTwitch, channelTwitch, 'user does not exist.') + } + + const match = await profile.related('matches').query().where('id', matchProfile.id).first() + + if (match === null) { + return false + } + + profile.mismatches.push(matchProfile.id) + + await profile.save() + + await profile.related('matches').detach([matchProfile.id]) + + return true + } + + public async mismatch ({ userTwitch, channelTwitch }: BASE) { + const { profile } = await this.findProfileOrCreateByChatOwner(userTwitch, channelTwitch) + + const profileId = profile.rolls.shift() + + if (profileId === undefined) { + Logger.error(`Could not find rolls to mismatch. userTwitch.id:${String(userTwitch.id)}, profile.id: ${String(profile.id)}`) + throw this.error( + MessageType.ERROR, userTwitch, channelTwitch, 'looks like you\'re not currently matching with anyone.') + } + + profile.mismatches.push(profileId) + + await profile.save() + } + + public async addEmotes ({ userTwitch, channelTwitch, emotes }: ADDEMOTES) { + const { profile } = await this.findProfileOrCreateByChatOwner(userTwitch, channelTwitch) + + profile.favoriteEmotes = emotes + + await profile.save() + } + + private async findUserByProfile (profile: Profile) { + const user = await User.find(profile.userId) + + if (user === null) { + // Shouldn't hit this at all. + Logger.error(`Tried to find a profile's user. profile.id: ${profile.id}, profile.userId: ${profile.userId}`) + // Ignore request. + throw new Error() + } + + return user + } + + private async findProfileOrCreateByChatOwner (user: User | NameAndId, channel: User | NameAndId): + Promise<{ user: User, chatOwnerUser: User, profile: Profile }> { + const userModel = user instanceof User ? user : await User.findBy('twitchID', user.id) + const chatOwnerUserModel = channel instanceof User ? channel : await User.findBy('twitchID', channel.id) + let profileModel: Profile | null + + if (userModel === null) { + throw this.error(MessageType.UNREGISTERED, user, channel) + } + + if (chatOwnerUserModel === null) { + // If this is hit, chat owner might've deleted their user. TODO: HANDLE + Logger.error(`Tried to find an unknown chat owner user. channelId: ${channel instanceof User ? String(channel.id) : String(channel.name)}`) + // Ignore request. + throw new Error() + } + + profileModel = await userModel.related('profile').query().where('chatUserId', chatOwnerUserModel.id).first() + + if (profileModel === null) { + // Register this profile for the user & continue. + profileModel = await userModel.related('profile').create({ + chatUserId: chatOwnerUserModel.id, + rolls: [], + mismatches: [], + nextRolls: DateTime.fromJSDate(new Date()), + }) + } + + return { user: userModel, chatOwnerUser: chatOwnerUserModel, profile: profileModel } + } + + private async rollUntilAvailableUser (rolls: number[]) { + if (rolls.length === 0) { + return new Error() + } + + const profile = await Profile.find(rolls[0]) + if (profile === null) { + // User probably deleted, reroll again. + rolls.shift() + return this.rollUntilAvailableUser(rolls) + } + + const user = await this.findUserByProfile(profile) + + return { rolls, user, profile } + } + + private error (type: MessageType, user: User | NameAndId, channel: User | NameAndId, message?: string) { + const error = new Error(type) + + ;(error as any).data = {} + + if (user instanceof User) { + (error as any).data.userTwitch = { id: user.twitchID, name: user.name } + } else { + (error as any).data.userTwitch = { id: user.id, name: user.name } + } + + if (channel instanceof User) { + (error as any).data.channelTwitch = { id: channel.twitchID, name: channel.name } + } else { + (error as any).data.channelTwitch = { id: channel.id, name: channel.name } + } + + if (message !== undefined) { + (error as any).data.result = { value: message } + } + + return error + } + + // https://stackoverflow.com/a/12646864 + private durstenfeldShuffle (array: any[]) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]] + } + } +} + +/** + * This makes our service a singleton + */ +export default new Handler() diff --git a/app/Services/Match.ts b/app/Services/Match.ts deleted file mode 100644 index 5dc7706..0000000 --- a/app/Services/Match.ts +++ /dev/null @@ -1,185 +0,0 @@ -import Logger from '@ioc:Adonis/Core/Logger' -import Database from '@ioc:Adonis/Lucid/Database' -import Profile from 'App/Models/Profile' -import User from 'App/Models/User' -import { BASE, ROLLMATCH, MessageType, UNMATCH } from 'befriendlier-shared' - -class Match { - /** - * MAKE SURE TO CATCH ERRORS. - */ - public async rollMatch ({ userTwitch, channelTwitch }: ROLLMATCH) { - const { chatOwnerUser, profile } = await this.findProfileByChatOwner(userTwitch.id, channelTwitch.id) - - if (profile.rolls.length > 0) { - // Return match - const { rolls, user: matchUser, profile: matchProfile } = await this.rollUntilAvailableUser(profile.rolls) - - // We don't need to save the array if it's not been edited. - if (rolls instanceof Array && rolls.length !== profile.rolls.length) { - profile.rolls = rolls as number[] - await profile.save() - } - - return { user: matchUser as User, profile: matchProfile as Profile } - } - - if (profile.nextRoll.diffNow('hours').hours < 0) { - throw new Error(MessageType.TAKEABREAK) - } - - await profile.preload('matches') - - // Find matches in the same chat, filter away mismatches and current matches, limit to 10. - const profiles = await Profile.query() - .where('chatUserId', chatOwnerUser.id) - .whereNotIn('id', profile.mismatches.concat(profile.matches.map(match => match.id))) - .limit(10) - - // Shuffle the array! - this.durstenfeldShuffle(profiles) - - profile.rolls = profiles.map(i => i.id) - - // Ratelimit user's rolls to every 5 hours. - profile.nextRoll = profile.nextRoll.plus({ hours: 5 }) - - await profile.save() - - const user = await this.findUserByProfile(profiles[0]) - - return { user: user, profile: profiles[0] } - } - - /** - * MAKE SURE TO CATCH ERRORS. - */ - public async match ({ userTwitch, channelTwitch }: BASE) { - const { user, profile } = await this.findProfileByChatOwner(userTwitch.id, channelTwitch.id) - - const profileId = profile.rolls.shift() - - const matchProfile = await Profile.find(profileId) - - if (matchProfile === null) { - // Shouldn't hit here, maybe user deleted as soon as they tried to match. - Logger.error(`Tried to match with an unknown profile. profileId: ${String(profileId)}`) - throw new Error(MessageType.TAKEABREAK) - } - - // Add match for this profile. - await profile.related('matches').attach([matchProfile.id]) - - // Check if matched profile also has this user as a match, if so, announce match to both users. - const hasMatched = await Database.query().from('matches_lists').where({ - profile_id: matchProfile.id, - match_user_id: user.id, - }).first() - - const matchUser = await this.findUserByProfile(matchProfile) - - if (matchUser === null) { - // Shouldn't hit here, maybe user deleted as soon as they tried to match. - Logger.error(`Tried to match with an unknown user. userId:${matchProfile.userId}, profileId: ${String(profileId)}`) - throw new Error(MessageType.TAKEABREAK) - } - - await profile.save() - - if (hasMatched !== null) { - return { attempt: MessageType.SUCCESS, user, matchUser } - } else { - return { attempt: MessageType.MATCH } - } - } - - /** - * MAKE SURE TO CATCH ERRORS. - */ - public async unmatch ({ userTwitch, matchUserTwitch, channelTwitch }: UNMATCH) { - const { profile } = await this.findProfileByChatOwner(userTwitch.id, channelTwitch.id) - - const matchUser = await User.findBy('twitchID', matchUserTwitch.id) - - if (matchUser === null) { - // Shouldn't hit here, maybe user deleted as soon as they tried to unmatch. - Logger.error(`Tried to match with an unknown user. matchUserTwitch.id:${String(matchUserTwitch.id)}, matchUserTwitch.name:${String(matchUserTwitch.name)}, profileId: ${String(profile.id)}`) - throw new Error(MessageType.TAKEABREAK) - } - - const res = await Database.query().from('matches_lists').where({ - profile_id: profile.id, - match_user_id: matchUser.id, - }).delete() - - Logger.debug(JSON.stringify(res)) - } - - private async findUserByProfile (profile: Profile) { - const user = await User.find(profile.userId) - - if (user === null) { - // Shouldn't hit this at all. - Logger.error(`Tried to find a profile's user. profile.id: ${profile.id}, profile.userId: ${profile.userId}`) - throw new Error() - } - - return user - } - - private async rollUntilAvailableUser (rolls: number[]) { - if (rolls.length === 0) { - return new Error(MessageType.TAKEABREAK) - } - - const profile = await Profile.find(rolls[0]) - if (profile === null) { - // User probably deleted, reroll again. - rolls.shift() - return this.rollUntilAvailableUser(rolls) - } - - const user = await this.findUserByProfile(profile) - - return { rolls, user, profile } - } - - private async findProfileByChatOwner (userId: string, channelId: string) { - const user = await User.findBy('twitchID', userId) - if (user === null) { - throw new Error(MessageType.UNREGISTERED) - } - - const chatOwnerUser = await User.findBy('twitchID', channelId) - if (chatOwnerUser === null) { - // If this is hit, chat owner might've deleted their user. TODO: HANDLE - Logger.error(`Tried to find an unknown chat owner user. channelId: ${channelId}`) - // Ignore request. - throw new Error() - } - - let profile = await user.related('profile').query().where('chatUserId', chatOwnerUser.id).first() - - if (profile === null) { - // Register this profile for the user & continue. - profile = await user.related('profile').create({ - chatUserId: chatOwnerUser.id, - }) - } - - return { user, chatOwnerUser, profile } - } - - // https://stackoverflow.com/a/12646864 - private durstenfeldShuffle (array: any[]) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]] - } - } -} - -/** - * This makes our service a singleton - */ -export default new Match() diff --git a/app/Services/Ws.ts b/app/Services/Ws.ts index d492c92..d4107a7 100644 --- a/app/Services/Ws.ts +++ b/app/Services/Ws.ts @@ -1,23 +1,63 @@ import bourne from '@hapi/bourne' +import Twitch from '@ioc:Adonis/Addons/Twitch' import Logger from '@ioc:Adonis/Core/Logger' import Server from '@ioc:Adonis/Core/Server' import { schema, validator } from '@ioc:Adonis/Core/Validator' -import { BASE, More, ROLLMATCH, SocketMessage, UNMATCH } from 'befriendlier-shared' +import User from 'App/Models/User' +import { + ADDEMOTES, + BASE, JOINCHAT, + MessageType, + More, + NameAndId, + REQUESTRESPONSE, + ROLLMATCH, + UNMATCH, +} from 'befriendlier-shared' import { IncomingMessage } from 'http' import { Socket } from 'net' import WS from 'ws' -import Matching from './Match' +import TwitchConfig from '../../config/twitch' +import Handler from './Handler' +import PQueue from 'p-queue' -interface Websocket extends WS { - isAlive: boolean +interface Token { + expiration: Date + superSecret: string + refreshToken: string +} + +export interface ExtendedWebSocket extends WS { + id: string connection: Socket + channels: NameAndId[] + isAlive: boolean +} + +interface ExtendedJOINCHAT extends JOINCHAT { + socketId: string +} + +interface REQUEST { + id: string + sockets: Array<{ id: string, value: any }> + func: Function + value: any + by: any + total: number } class Ws { public isReady = false public server: WS.Server + private readonly requests = new Map() + + private token: Token + private reconnectTimeout: NodeJS.Timeout - public start (callback: (socket: Websocket, request: IncomingMessage) => void) { + public readonly queue = new PQueue({ concurrency: 1 }) + + public start (callback: (socket: ExtendedWebSocket, request: IncomingMessage) => void) { this.server = new WS.Server({ server: Server.instance }) this.server.on('connection', callback) this.isReady = true @@ -25,140 +65,455 @@ class Ws { this.server.on('close', () => { clearInterval(this.interval) }) + + // Connect to Twitch + this.token = { + expiration: new Date(Date.now() - 1), + superSecret: TwitchConfig.superSecret, + refreshToken: TwitchConfig.refreshToken, + } + + this.startTwitch() } - // Is called from "start/socket.ts" file. - public onMessage (socket: Websocket, msg: WS.Data) { - switch (msg) { - case '10': - // Message is a PONG response, keep this socket alive. - this.heartbeat(socket) - break - default: - this.handleMessage(socket, msg) - break + public startTwitch () { + if (this.reconnectTimeout !== undefined) { + clearTimeout(this.reconnectTimeout) } + + this.refreshTwitchToken(this.token).then(async res => { + if (res.superSecret !== this.token.superSecret) { + this.token = res + + // Send new token to all clients. + for (const client of this.server.clients) { + const socket = client as ExtendedWebSocket + + socket.send(this.socketMessage(MessageType.TOKEN, JSON.stringify(this.token))) + } + } + + this.reconnectTimeout = setTimeout(() => this.startTwitch(), 5000) + }).catch((error) => { + Logger.error({ err: error }, 'Twitch.start(): Something went wrong trying to refresh token!') + this.reconnectTimeout = setTimeout(() => this.startTwitch(), 1000) + }) } - private handleMessage (socket: Websocket, msg: WS.Data) { - const json = bourne.parse(msg, null, { protoAction: 'remove' }) - - validator.validate({ - schema: this.validationSchema, - data: json, - messages: { - type: 'Invalid type.', - }, - cacheKey: 'websocket', - }).then(async res => { - switch (res.type) { - case SocketMessage.ROLLMATCH: { - if (res.data !== undefined) { - const rm: ROLLMATCH = JSON.parse(res.data) - const result = await Matching.rollMatch(rm) - switch (rm.more) { - case More.NONE: - rm.result = { value: `New match's bio: ${result.profile.bio.length > 64 ? `${result.profile.bio.substr(0, 32)}...` : result.profile.bio}, reply with !more, !match or !no` } - socket.send(this.socketMessage(SocketMessage.ROLLMATCH, JSON.stringify(rm))) - break - case More.BIO: - rm.result = { value: `Full bio: ${result.profile.bio}.` } - socket.send(this.socketMessage(SocketMessage.ROLLMATCH, JSON.stringify(rm))) - break - case More.FAVORITEEMOTES: - rm.result = { value: `Match's favorite emotes: ${result.profile.favoriteEmotes.length > 0 ? result.profile.favoriteEmotes.map(emote => emote.name).join(' ') : 'None.'}` } - socket.send(this.socketMessage(SocketMessage.ROLLMATCH, JSON.stringify(rm))) - break - case More.FAVORITESTREAMERS: - rm.result = { value: `Match's favorite streamers: ${result.user.favoriteStreamers.length > 0 ? result.user.favoriteStreamers.map(streamer => streamer.name).join(', ') : 'None'}.` } - socket.send(this.socketMessage(SocketMessage.ROLLMATCH, JSON.stringify(rm))) - break + // Is called from "start/socket.ts" file. + public async onMessage (socket: ExtendedWebSocket, msg: WS.Data) { + return await new Promise((resolve, reject) => { + Logger.debug({ msg }, 'Ws.onMessage()') + let json + + try { + json = bourne.parse(msg, null, { protoAction: 'remove' }) + } catch (error) { + // Data's not JSON. + Logger.error({ err: error }, 'Ws.onMessage(): Error with parsing websocket data.') + return + } + + validator.validate({ + schema: this.validationSchema, + data: json, + messages: { + type: 'Invalid type.', + }, + cacheKey: 'websocket', + }).then(async res => { + switch (res.type) { + case MessageType.PING: { + if (res.data !== undefined) { + socket.send(this.socketMessage(MessageType.PING, res.data)) } + // TODO: HANDLE HEALTHCHECK + break } - break - } - case SocketMessage.MATCH: { - if (res.data !== undefined) { - const rm: BASE = JSON.parse(res.data) - const result = await Matching.match(rm) - switch (result.attempt) { - case SocketMessage.MATCH: { - // Attempted to match. Must wait for receiving end. - rm.result = { - value: 'You are attempting to match with a new user. Good luck!' + - // eslint-disable-next-line comma-dangle - 'You will receive a notification on a successful match!' + case MessageType.ROLLMATCH: { + if (res.data !== undefined) { + const rm: ROLLMATCH = JSON.parse(res.data) + const { user, profile } = await Handler.rollMatch(rm) + + switch (rm.more) { + case More.NONE: + rm.result = { value: `new match's bio: ${profile.bio.length > 64 ? `${String(profile.bio.substr(0, 32))}...` : String(profile.bio)}, reply with %prefix%more, %prefix%match or %prefix%no` } + socket.send(this.socketMessage(MessageType.ROLLMATCH, JSON.stringify(rm))) + break + case More.BIO: + rm.result = { value: `full bio: ${String(profile.bio)}` } + socket.send(this.socketMessage(MessageType.ROLLMATCH, JSON.stringify(rm))) + break + case More.FAVORITEEMOTES: + rm.result = { value: `match's favorite emotes: ${profile.favoriteEmotes.length > 0 ? String(profile.favoriteEmotes.map(emote => emote.name).join(' ')) : 'None.'}` } + socket.send(this.socketMessage(MessageType.ROLLMATCH, JSON.stringify(rm))) + break + case More.FAVORITESTREAMERS: { + await user.preload('favoriteStreamers') + + rm.result = { value: `match's favorite streamers: ${user.favoriteStreamers.length > 0 ? String(user.favoriteStreamers.map(streamer => streamer.name).join(', ')) : 'None'}.` } + socket.send(this.socketMessage(MessageType.ROLLMATCH, JSON.stringify(rm))) + break } - socket.send(this.socketMessage(SocketMessage.MATCH, JSON.stringify(rm))) - break } - case SocketMessage.SUCCESS: { - if (result.matchUser !== undefined) { + } + break + } + case MessageType.MATCH: { + if (res.data !== undefined) { + const data: BASE = JSON.parse(res.data) + const result = await Handler.match(data) + switch (result.attempt) { + case MessageType.MATCH: { + // Attempted to match. Must wait for receiving end. + data.result = { + value: 'you are attempting to match with a new user. Good luck! ' + + // eslint-disable-next-line comma-dangle + 'You will receive a notification on a successful match!' + } + socket.send(this.socketMessage(MessageType.MATCH, JSON.stringify(data))) + break + } + case MessageType.SUCCESS: { + if (result.matchUser !== undefined) { // Successfully matched! - rm.result = { - matchUsername: result.matchUser.name, - value: 'You have matched with %s! Send them a message?', + data.result = { + matchUsername: result.matchUser.name, + value: 'you have matched with %s%! Send them a message?', + } + socket.send(this.socketMessage(MessageType.SUCCESS, JSON.stringify(data))) } - socket.send(this.socketMessage(SocketMessage.SUCCESS, JSON.stringify(rm))) + break } - break } } + break } - break + case MessageType.MISMATCH: { + if (res.data !== undefined) { + const data: BASE = JSON.parse(res.data) + await Handler.mismatch(data) + data.result = { value: 'FeelsBadMan Better luck next time!' } + socket.send(this.socketMessage(MessageType.MISMATCH, JSON.stringify(data))) + } + break + } + case MessageType.UNMATCH: { + if (res.data !== undefined) { + const data: UNMATCH = JSON.parse(res.data) + const hasUnmatched = await Handler.unmatch(data) + data.result = { + value: `you have ${hasUnmatched + ? `successfully unmatched with ${String(data.matchUserTwitch.name)}.` + : 'unsuccessfully unmatched. FeelsBadMan In fact, you were never matched with them to begin with...' + }`, + } + socket.send(this.socketMessage(MessageType.UNMATCH, JSON.stringify(data))) + } + break + } + case MessageType.JOINCHAT: { + if (res.data !== undefined) { + const data: ExtendedJOINCHAT = JSON.parse(res.data) + const user = await User.findBy('twitchID', data.joinUserTwitch.id) + + data.socketId = socket.id + + if (user === null) { + data.result = { + value: 'user does not exist in the database.' + + // eslint-disable-next-line comma-dangle + 'Can only add favorited or otherwise registered users.' + } + socket.send(this.socketMessage(MessageType.ERROR, JSON.stringify(data))) + return + } + + user.host = true + await user.save() + + this.request(data, this.addHost) + } + break + } + case MessageType.CHATS: { + if (res.data !== undefined) { + const data = JSON.parse(res.data) + if (data.requestTime !== undefined) { + this.parseRequestResponse(socket, data) + } else { + // Client has sent back a list of all channels. + + const socketChannels: Array<{ id: string, channels: string[] }> = [] + + /** + * Make a validation check to make sure that this bot + * hasn't joined any duplicate channels from other bots. + */ + + for (const client of this.server.clients) { + const sock = client as ExtendedWebSocket + + socketChannels.push({ id: sock.id, channels: sock.channels.map(channel => channel.id) }) + } + + for (let index = 0; index < data.value.length; index++) { + const channel = data.value[index] + + const foundChannel = socketChannels.find(sock => sock.channels.includes(channel.id)) + + if (foundChannel !== undefined) { + const data = { leaveUserTwitch: channel } + socket.send(this.socketMessage(MessageType.LEAVECHAT, JSON.stringify(data))) + } else { + socket.channels.push({ id: channel.id, name: channel.name }) + } + } + + // WARNING: THE FOLLOWING CODE IS SPAGHETTI. TODO: FIX THIS MESS. + + // Add channels not yet handled by any client. + const allHostedUsers = (await User.query().where({ host: true })).map(user => { + return { id: user.twitchID, name: user.name } + }) + + const flattenedArrayOfJoinedChannelIds = + socketChannels.map(sock => sock.channels).flat() + + const userIdsNotHosted = + allHostedUsers + .filter(user => !flattenedArrayOfJoinedChannelIds.includes(user.id)) + .map(user => user.id) + + for (let index = 0; index < userIdsNotHosted.length; index++) { + const userIdNotHosted = userIdsNotHosted[index] + const userNotHosted = allHostedUsers.find(user => user.id === userIdNotHosted) as NameAndId + + // Get socket with lowest joined channels. + socketChannels.sort((a, b) => { + return a.channels.length - b.channels.length + }) + + // Udate current temporary array with channel count + socketChannels[0].channels.push(userNotHosted.id) + + for (const client of this.server.clients) { + const sock = client as ExtendedWebSocket + if (sock.id === socketChannels[0].id) { + const data = { joinUserTwitch: userNotHosted } + + sock.channels.push(userNotHosted) + + sock.send(this.socketMessage(MessageType.JOINCHAT, JSON.stringify(data))) + break + } + } + } + } + } + break + } + case MessageType.WELCOME: { + if (res.data === undefined) { + // A new websocket has connected/reconnected. + + // Send the current Twitch chat connection token + socket.send(this.socketMessage(MessageType.TOKEN, JSON.stringify(this.token))) + + // Request an array of all chats client is connected to. + socket.send(this.socketMessage(MessageType.CHATS, JSON.stringify(''))) + } + break + } + case MessageType.ADDEMOTES: { + if (res.data !== undefined) { + const data: ADDEMOTES = JSON.parse(res.data) + + // static-cdn.jtvnw.net/emoticons/v1/${matchesTwitch.id}/3.0 + await Handler.addEmotes(data) + + data.result = { value: `Successfully set the following emotes: ${data.emotes.map(emote => emote.name).join(' ')}` } + socket.send(this.socketMessage(MessageType.ADDEMOTES, JSON.stringify(data))) + } + break + } + // case MessageType.TOKEN: { + // socket.send(this.socketMessage(MessageType.TOKEN, JSON.stringify(this.token))) + // break + // } + } + resolve() + }).catch(error => { + if (error.message === MessageType.UNREGISTERED) { + socket.send(this.socketMessage(MessageType.UNREGISTERED, JSON.stringify(error.data))) + } else if (error.message === MessageType.TAKEABREAK) { + socket.send(this.socketMessage(MessageType.TAKEABREAK, JSON.stringify(error.data))) + } else if (error.message === MessageType.ERROR && error.data !== undefined) { + socket.send(this.socketMessage(MessageType.ERROR, JSON.stringify(error.data))) + } else if (error.message !== undefined) { + Logger.error({ err: error }, 'Ws.handleMessage()') + // socket.send(this.socketMessage(MessageType.ERROR, JSON.stringify(error))) } - case SocketMessage.UNMATCH: { - if (res.data !== undefined) { - const rm: UNMATCH = JSON.parse(res.data) - await Matching.unmatch(rm) - rm.result = { value: `You have successfully unmatched ${String(rm.matchUserTwitch.name)}.` } - socket.send(this.socketMessage(SocketMessage.UNMATCH, JSON.stringify(rm))) + // Else ignore. + reject(new Error()) + }) + }) + } + + public onClose (socket: ExtendedWebSocket, code: number, reason: string) { + Logger.warn(`WEBSOCKET CLOSED: [${socket.id}] from ${prettySocketInfo(socket.connection)}, code: ${code}${reason.length > 0 ? `, reason:\n${reason}` : ''}`) + } + + public onError (socket: ExtendedWebSocket, error: Error) { + Logger.error({ err: error }, `WEBSOCKET ERROR: [${socket.id}] from ${prettySocketInfo(socket.connection)}.`) + } + + // https://github.com/websockets/ws#how-to-detect-and-close-broken-connections + public heartbeat (socket: ExtendedWebSocket, data: Buffer) { + if (data.length > 0) { + Logger.info(`PING MESSAGE: [${socket.id}] from ${prettySocketInfo(socket.connection)} \n${data.toString()}`) + } + socket.isAlive = true + } + + /** + * Tell a bot to join the following user's chat. + */ + private addHost (request: REQUEST) { + // Make sure channel isn't already added by another bot. + const foundChannelBot = request.sockets.find(sockRes => sockRes.value.includes(request.by.joinUserTwitch.name)) + + if (foundChannelBot !== undefined) { + for (const client of this.server.clients) { + const socket = client as ExtendedWebSocket + + if (socket.id === request.by.socketId) { + const data: BASE = { + channelTwitch: request.by.channelTwitch, + userTwitch: request.by.userTwitch, + result: { + value: `channel already added by bot "${foundChannelBot.id}".`, + }, } + + socket.send(this.socketMessage(MessageType.ERROR, JSON.stringify(data))) break } } - }).catch(err => { - if (err.message === SocketMessage.UNREGISTERED) { - socket.send(this.socketMessage(SocketMessage.UNREGISTERED, '')) - } else if (err.message === SocketMessage.TAKEABREAK) { - socket.send(this.socketMessage(SocketMessage.TAKEABREAK, '')) - } else if (err.message !== undefined) { - Logger.error('Ws.handleMessage()', err) - socket.send(this.socketMessage(SocketMessage.ERROR, err)) + + this.requests.delete(request.id) + return + } + + // Get socket with lowest joined channels. + const sortedAsc = request.sockets.sort((a, b) => { + return a.value.length - b.value.length + }) + + for (const client of this.server.clients) { + const socket = client as ExtendedWebSocket + + if (socket.id === sortedAsc[0].id) { + const data = { joinUserTwitch: request.by.joinUserTwitch } + + socket.channels.push(request.by.joinUserTwitch) + + socket.send(this.socketMessage(MessageType.JOINCHAT, JSON.stringify(data))) + + this.requests.delete(request.id) + break } - // Else ignore. + } + } + + private parseRequestResponse (socket: ExtendedWebSocket, message: REQUESTRESPONSE) { + const req = this.requests.get(message.requestTime) + + if (req === undefined) { + Logger.error(`Ws.parseRequestResponse(): Undefined requestResponse by socket ${socket.id}: %O`, message) + return + } + + req.sockets.push({ id: socket.id, value: message.value }) + + if (req.sockets.length === req.total) { + req.func.bind(this)(req) + } + } + + private request (by, func, value?) { + const requestTime = Date.now().toString() + this.requests.set(requestTime, { + id: requestTime, + sockets: [], + func, + value, + by, + total: 0, + }) + + // Force into REQUEST because we've defined it above, there's no risk of it being undefined. + const req = this.requests.get(requestTime) as REQUEST + + this.server.clients.forEach((socket: ExtendedWebSocket) => { + socket.send(this.socketMessage(MessageType.CHATS, JSON.stringify(requestTime))) + req.total++ }) } - // https://github.com/websockets/ws#how-to-detect-and-close-broken-connections - private heartbeat (socket) { - socket.isAlive = true + public socketMessage (type: MessageType, data: string) { + return JSON.stringify({ type: type, data: data, timestamp: Date.now() }) } - private socketMessage (type: SocketMessage, data: string) { - return JSON.stringify({ type: type, data: data }) + private async refreshTwitchToken (token: Token): Promise { + const newToken = { ...token } + if (Date.now() > token.expiration.getTime()) { + // Generate a token! + const twitch = await Twitch.refreshToken(this.token.refreshToken) + + if (twitch === null) { + throw new Error('Couldn\'t login to Twitch!') + } + + newToken.expiration = new Date(Date.now() + (twitch.expires_in * 1000)) + newToken.superSecret = twitch.access_token + newToken.refreshToken = twitch.refresh_token + + return newToken + } + + return newToken } private readonly validationSchema = schema.create({ - type: schema.enum(Object.values(SocketMessage)), + type: schema.enum(Object.values(MessageType)), data: schema.string.optional(), + timestamp: schema.number(), }) private readonly interval = setInterval(() => { - // Forceful "any" to make sure we can use "isAlive". - this.server.clients.forEach((ws: Websocket) => { - console.log('HEARTBEAT PINGING', ws.connection.remoteAddress) + if (!this.isReady) { + return + } + + this.server.clients.forEach((socket: ExtendedWebSocket) => { + Logger.debug(`PINGING: [${socket.id}] from ${prettySocketInfo(socket.connection)}.`) - if (!ws.isAlive) { - return ws.terminate() + if (!socket.isAlive) { + Logger.warn(`PING FAILED: [${socket.id}] from ${prettySocketInfo(socket.connection)}. TERMINATING.`) + return socket.terminate() } - ws.isAlive = false + socket.isAlive = false // Ping - ws.send('9') + socket.ping() }) - }, 30000) + }, 1000) +} + +export function prettySocketInfo (connection: Socket) { + return `(${String(connection.remoteFamily)}) ${String(connection.remoteAddress)}:${String(connection.remotePort)}` } /** diff --git a/config/twitch.ts b/config/twitch.ts index d421581..d14392b 100644 --- a/config/twitch.ts +++ b/config/twitch.ts @@ -16,6 +16,16 @@ const TwitchConfig = { */ redirectURI: Env.getOrFail('TWITCH_REDIRECT_URI') as string, + /** + * SUPERSECRET TOKEN + */ + superSecret: Env.getOrFail('TWITCH_BOT_ACCESS_TOKEN') as string, + + /** + * REFRESH TOKEN + */ + refreshToken: Env.getOrFail('TWITCH_BOT_REFRESH_TOKEN') as string, + /** * Twitch username. Used to make bot join default channel. */ diff --git a/database/seeders/Initialize.ts b/database/seeders/0_Initialize.ts similarity index 97% rename from database/seeders/Initialize.ts rename to database/seeders/0_Initialize.ts index 34600ac..7c90448 100644 --- a/database/seeders/Initialize.ts +++ b/database/seeders/0_Initialize.ts @@ -8,6 +8,7 @@ import TwitchConfig from '../../config/twitch' export default class InitializeSeeder extends BaseSeeder { public async run () { await User.firstOrCreate({ + id: 0, twitchID: TwitchConfig.user.id, name: TwitchConfig.user.name, displayName: TwitchConfig.user.name, diff --git a/database/seeders/Development.ts b/database/seeders/1_Development.ts similarity index 97% rename from database/seeders/Development.ts rename to database/seeders/1_Development.ts index 9fb13d6..cfa3108 100644 --- a/database/seeders/Development.ts +++ b/database/seeders/1_Development.ts @@ -1,6 +1,7 @@ import BaseSeeder from '@ioc:Adonis/Lucid/Seeder' import BannedUser from 'App/Models/BannedUser' import User from 'App/Models/User' +import { DateTime } from 'luxon' export default class DevelopmentSeeder extends BaseSeeder { public static developmentOnly = true @@ -14,6 +15,7 @@ export default class DevelopmentSeeder extends BaseSeeder { name: 'testuser', displayName: '[DEVELOPMENT ACCOUNT]', avatar: 'https://brand.twitch.tv/assets/emotes/lib/kappa.png', + createdAt: DateTime.fromJSDate(new Date(0)), }, { twitchID: '450408427', @@ -54,7 +56,7 @@ export default class DevelopmentSeeder extends BaseSeeder { const uniqueKey = { chatUserId: 0 } const profile = await user.related('profile').updateOrCreate(uniqueKey, { - enabled: false, + enabled: true, chatUserId: 0, }) diff --git a/package.json b/package.json index b3bafba..8655f7b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "feather-icons": "^4.28.0", "got": "^11.5.1", "node-sass": "^4.14.1", + "p-queue": "^6.6.0", "parcel": "^2.0.0-nightly.352", "pg": "^8.3.0", "phc-argon2": "^1.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37d9502..a1a85f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ dependencies: feather-icons: 4.28.0 got: 11.5.1 node-sass: 4.14.1 + p-queue: 6.6.0 parcel: 2.0.0-nightly.352 pg: 8.3.0_pg@8.3.0 phc-argon2: 1.0.10 @@ -1460,6 +1461,7 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-ZJBsWauA837gRn2uCA/l3swZIXQlrRCbuht13FON80ht5M6RnGGl7Vk/cLnQr3vKo9eLURj0G3/PcoKO1Nk0ZA== + tarball: '@parcel/babel-ast-utils/-/babel-ast-utils-2.0.0-nightly.1976.tgz' /@parcel/babel-preset-env/2.0.0-nightly.354_@babel+core@7.10.5: dependencies: '@babel/core': 7.10.5 @@ -1482,6 +1484,7 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-M0e0iR0+/dH9wBuUWAGsAp1kCdxLJAkaQDldbh8iHN7oJJuT+V68FeDMS8xQVSN699KKo2HPY+ZwS7XdqRh4Nw== + tarball: '@parcel/babylon-walk/-/babylon-walk-2.0.0-nightly.1976.tgz' /@parcel/bundler-default/2.0.0-nightly.354: dependencies: '@parcel/diagnostic': 2.0.0-nightly.354 @@ -1494,6 +1497,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-TjdQVkGnwIHJkV0U/QGR6+l1jStVFy3MkLUPdweUvvx9dbiPy9gmFkOEWcK9BQ9tV0BNshCIXZc9tbrIFPAFCg== + tarball: '@parcel/bundler-default/-/bundler-default-2.0.0-nightly.354.tgz' /@parcel/cache/2.0.0-nightly.354_07ed7636cf5a8f6bbdb71b73c62eef68: dependencies: '@parcel/core': 2.0.0-nightly.352_07ed7636cf5a8f6bbdb71b73c62eef68 @@ -1610,12 +1614,14 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-aWf3ckd9aPS6wbJWnjo6KqqGhACGJaEDWkkwjs9gtbeRBPOycs4ce7BaVCIuDsjmEqaTsubT2EFWoXUUrMsELQ== + tarball: '@parcel/diagnostic/-/diagnostic-2.0.0-nightly.354.tgz' /@parcel/events/2.0.0-nightly.354: dev: false engines: node: '>= 10.0.0' resolution: integrity: sha512-VqB3baRPl9MeP7zZEvjvpBaiUP5myaJ6W+CV+ma6IhZcK9Rz4dZn/MthTKdEcoIMxx8epVEQAcz+VkF6Qxy6yQ== + tarball: '@parcel/events/-/events-2.0.0-nightly.354.tgz' /@parcel/fs-write-stream-atomic/2.0.0-nightly.1976: dependencies: graceful-fs: 4.2.4 @@ -1625,6 +1631,7 @@ packages: dev: false resolution: integrity: sha512-vUznO0mUhNZ31GbIc/4mIG6pECa4csRtQTzAExkQVaoDnBF0a0VwQV5Bffq2bucQ/kJYlgvFGRy1UfzV+QK6Hg== + tarball: '@parcel/fs-write-stream-atomic/-/fs-write-stream-atomic-2.0.0-nightly.1976.tgz' /@parcel/fs/2.0.0-nightly.354_07ed7636cf5a8f6bbdb71b73c62eef68: dependencies: '@parcel/core': 2.0.0-nightly.352_07ed7636cf5a8f6bbdb71b73c62eef68 @@ -1653,6 +1660,7 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-IIYOJwEJjYCKspiTZC/PmBwxwjlWVJWZeuPACt0AUkwwIifvg7aNy9m+vWzHb5xKbR4hHuUHMwfAkr6EbALUUw== + tarball: '@parcel/logger/-/logger-2.0.0-nightly.354.tgz' /@parcel/markdown-ansi/2.0.0-nightly.354: dependencies: chalk: 2.4.2 @@ -1671,6 +1679,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-8FKg3yUphMv81MpppH9IJJ2mau1v2Utmz/YZXmoEBhUO3Zl0UnFs99Rj9zEAGy+RMUJYDktpiPX/RmavcFKr3Q== + tarball: '@parcel/namer-default/-/namer-default-2.0.0-nightly.354.tgz' /@parcel/node-libs-browser/2.0.0-nightly.1976: dependencies: assert: 2.0.0 @@ -1723,6 +1732,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-nn42hwKzjqsWYTXfWqOUCfKWTcX/eSsIy9RFQvpodlStHIuW8bau3fKZrZ+XfMNnN/NUWXFgNrFOX5JM6u5ARg== + tarball: '@parcel/optimizer-cssnano/-/optimizer-cssnano-2.0.0-nightly.354.tgz' /@parcel/optimizer-data-url/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1734,6 +1744,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-Vo6MLtqvt30HTaQZqKKoE0kR3DuesZ+yRHWYuajSf0bY5w1YYAn7e3t3gwpF0lKsvws6fGEU+te/s3ipN1GJEg== + tarball: '@parcel/optimizer-data-url/-/optimizer-data-url-2.0.0-nightly.354.tgz' /@parcel/optimizer-htmlnano/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1746,6 +1757,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-EqgIVQawvHPggpEY93r3WbMgE6260AM3xKfCLIWaKgQwJwdcP3675GTqhYW42YtFmLkbB6P16FPScyG8vbYIbA== + tarball: '@parcel/optimizer-htmlnano/-/optimizer-htmlnano-2.0.0-nightly.354.tgz' /@parcel/optimizer-terser/2.0.0-nightly.354_07ed7636cf5a8f6bbdb71b73c62eef68: dependencies: '@parcel/core': 2.0.0-nightly.352_07ed7636cf5a8f6bbdb71b73c62eef68 @@ -1797,6 +1809,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-JZpofqSp+VP6pWGe7VB1bTkgVo6pFzRTovqx4r446fWaJCLzvQ+wpcD8zjtzKT+kx/LcEnzN2QCgJZPp5XWyjQ== + tarball: '@parcel/packager-css/-/packager-css-2.0.0-nightly.354.tgz' /@parcel/packager-html/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1810,6 +1823,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-3V5mkHdThB24tS/FxqXgNZoaoDp8R6CDdg1rDlqAI365Zue2csmwiAUyx0dAwuw7qkrC7szuq4UA7bp87uq1ww== + tarball: '@parcel/packager-html/-/packager-html-2.0.0-nightly.354.tgz' /@parcel/packager-js/2.0.0-nightly.354_07ed7636cf5a8f6bbdb71b73c62eef68: dependencies: '@babel/traverse': 7.10.5 @@ -1838,6 +1852,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-JVITwoxhex/2UkdYM8UQ5x/6PTA/u5OORYhL5DpZpKTDHu+ulMNfqZm7SkiUAYVwH/1/2qNO0ZKjq7qD9DjejA== + tarball: '@parcel/packager-raw-url/-/packager-raw-url-2.0.0-nightly.1976.tgz' /@parcel/packager-raw/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1847,6 +1862,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-UlQLWpSY7jdg2zQakTQcJajdPPhdlPFPJG1bnQBwZNtY5JFzUVCUuivKFk/39CeeZ3PH4YluMx0P8jtwmeIaJg== + tarball: '@parcel/packager-raw/-/packager-raw-2.0.0-nightly.354.tgz' /@parcel/packager-ts/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1856,6 +1872,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-nJk8ShavBaNuE2lvQp3epCRGTXUnnXEiCSpMueLyjcqnco08Um+4esJSOBfbmXUlnupHk7dMdEI4d+5uLrPneg== + tarball: '@parcel/packager-ts/-/packager-ts-2.0.0-nightly.354.tgz' /@parcel/plugin/2.0.0-nightly.354: dependencies: '@parcel/types': 2.0.0-nightly.354 @@ -1864,6 +1881,7 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-nG6PIZBrS6gT+v6HMsaYkRXspGv00a4InHwnOyZwQb4c35nk2D1k6Drtl4BCj7352UFW+nWJ0leZi5JFDBJzWA== + tarball: '@parcel/plugin/-/plugin-2.0.0-nightly.354.tgz' /@parcel/reporter-bundle-analyzer/2.0.0-nightly.1976: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1875,6 +1893,7 @@ packages: parcel: ^2.0.0-alpha.3.1 resolution: integrity: sha512-FJZSbXNG+mIy9zLOMdNyDNWkYjlcgLvk13X1fw03+cQpb9Fd3KyynMRAjIMm8FsppnC1aXaPeALFnyyUwdxqNA== + tarball: '@parcel/reporter-bundle-analyzer/-/reporter-bundle-analyzer-2.0.0-nightly.1976.tgz' /@parcel/reporter-bundle-buddy/2.0.0-nightly.1976: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1884,6 +1903,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-yeLpGpBOTCUqaa8r10qXj1zZ/K5G38iy9eD9REN3ULf+iAcrN3Zm5cfxTlrKLvVQ+VRtRHLojcd9+HexHHQGDw== + tarball: '@parcel/reporter-bundle-buddy/-/reporter-bundle-buddy-2.0.0-nightly.1976.tgz' /@parcel/reporter-cli/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1902,6 +1922,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-E3xhpEyw8dSHzXvq1GWV/r8gGIJa9/IQcjZdExQygcug2HAL8BxhOfybZNr/jYgDE/Y+cnBskK8uYhJHWp4OyQ== + tarball: '@parcel/reporter-cli/-/reporter-cli-2.0.0-nightly.354.tgz' /@parcel/reporter-dev-server/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1918,6 +1939,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-6JgKM15UsEiJB+9pqk5fG0MOR8silWzD/Ysy/sOeMsHeZSOKz7fAyZsIBcGRKmxyPy/YUMpOy+ov8GguKjubTA== + tarball: '@parcel/reporter-dev-server/-/reporter-dev-server-2.0.0-nightly.354.tgz' /@parcel/resolver-default/2.0.0-nightly.354: dependencies: '@parcel/node-resolver-core': 2.0.0-nightly.1976 @@ -1927,6 +1949,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-pskRCbChWdL2y20XMr6npkXvt7h6jzzS2zevstwBEdzlEY7kOPganRy0MamnT/ErmbiTfT4ucHQXSOFtqVWTwg== + tarball: '@parcel/resolver-default/-/resolver-default-2.0.0-nightly.354.tgz' /@parcel/runtime-browser-hmr/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1937,6 +1960,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-mvcJBNqXzfXWqQWJMHttC9LX8ifSHjfC0AIIVHQPDYpgSIISsp7XLeG7UCITZ/w0etc2sZgx7l5bqFHReWuvJA== + tarball: '@parcel/runtime-browser-hmr/-/runtime-browser-hmr-2.0.0-nightly.354.tgz' /@parcel/runtime-js/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1948,6 +1972,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-Abhj5vucZfm4ZT3y0tyXB4lwPZNrm9eC4BWkjD3fD2hnnvBfQ9NTnK8oOfbOPWmYjFW9sdneeSBhWYIUEqECXg== + tarball: '@parcel/runtime-js/-/runtime-js-2.0.0-nightly.354.tgz' /@parcel/runtime-react-refresh/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -1958,6 +1983,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-eNK0/2t0pkDz0TwFEuwV16PMAeAOMFFGKbOyohjIchYVZj5ti0XjzDx+IjMzTQElIXIpTf/QOm3yvf0qKnpihg== + tarball: '@parcel/runtime-react-refresh/-/runtime-react-refresh-2.0.0-nightly.354.tgz' /@parcel/scope-hoisting/2.0.0-nightly.354: dependencies: '@babel/generator': 7.10.5 @@ -1975,6 +2001,7 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-tq0i14AzLPR49w1E0rWpo2F0LiSWf5SGAUOi8WPeQw/t4/7MIB8Ex14yIMsePB01cYKrmEDj3/I99wQfwZDW1w== + tarball: '@parcel/scope-hoisting/-/scope-hoisting-2.0.0-nightly.354.tgz' /@parcel/source-map/2.0.0-alpha.4.13: dependencies: node-addon-api: 2.0.2 @@ -2045,6 +2072,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-pdK4BkDt33lWjiff4DkIjUCcF2dWI9oOfVZCvg3j0KiZnqfLzD7VwCLtKWKuDH2WkAQZqkddGVrNAd/jsN+9iQ== + tarball: '@parcel/transformer-css/-/transformer-css-2.0.0-nightly.354.tgz' /@parcel/transformer-graphql/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2054,6 +2082,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-PxNwOKjKF9hQZfeez3egJ2QnHsPeqAmfYOPWH+6oBjbhT4z3gmubSte+nfhkGQ6brgKBGSRzkfFykKxFV8g3Eg== + tarball: '@parcel/transformer-graphql/-/transformer-graphql-2.0.0-nightly.354.tgz' /@parcel/transformer-html/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2069,6 +2098,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-W0asr8n3b+GWefcjLI8kpH5tikzPNBbQezWHmRgKRPIv2qyWLbES5A2NV7k60E9oCel6msLtAcYX3eendpEZ0A== + tarball: '@parcel/transformer-html/-/transformer-html-2.0.0-nightly.354.tgz' /@parcel/transformer-inline-string/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2078,6 +2108,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-2O+po+SWV4O+e/HZtubjjWu35Cn+9kFhGDJYqOctJHDGElkzQmTr1IOqgtNdR77TxSLXCOmNwKGQRXV0rYdWfQ== + tarball: '@parcel/transformer-inline-string/-/transformer-inline-string-2.0.0-nightly.354.tgz' /@parcel/transformer-js/2.0.0-nightly.354_07ed7636cf5a8f6bbdb71b73c62eef68: dependencies: '@babel/core': 7.10.5 @@ -2114,6 +2145,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-9cT6lsuavqvRovcME4i3JgWvPLrUyiL+BYYTSuNIhnj0wPQp7I/KIcx7zOLl+lGyo4lLln3Wk7Lvvbam+GtS3g== + tarball: '@parcel/transformer-json/-/transformer-json-2.0.0-nightly.354.tgz' /@parcel/transformer-jsonld/2.0.0-nightly.1976: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2124,6 +2156,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-GWH5Xyk4lR58jGmE9EJhyusfSNDvVqFkZAOPzA0HHl0taUhIs1KExMd3Yi3STZ5WM0ARFfLojUP7sjrzdbkz1g== + tarball: '@parcel/transformer-jsonld/-/transformer-jsonld-2.0.0-nightly.1976.tgz' /@parcel/transformer-less/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2134,6 +2167,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-Wqn3Bu/1Vxc895S0XRlbHrYg8FA3jOeOYgadvKGU3Ht0bDIMao8TrEetRI1geejDGwwoKEHaXeJy/jKhI999Fg== + tarball: '@parcel/transformer-less/-/transformer-less-2.0.0-nightly.354.tgz' /@parcel/transformer-mdx/2.0.0-nightly.1976: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2143,6 +2177,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-Vl3hU9G7DAQ07cmXbD0xyRVUbZsPkSnYTvi4W/3H+bD+73Z2gB2BC5HbjUj5r3yd9noUvNCW8NahanpFeB0QPg== + tarball: '@parcel/transformer-mdx/-/transformer-mdx-2.0.0-nightly.1976.tgz' /@parcel/transformer-postcss/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2158,6 +2193,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-wPvrS9/KxOuXmNq97PRs9G7ib2WDMFwczwuAT0b+f93LYQIVh7xlisRS2CWof6qAg84XpmzL7ZEbN6KlmN86Vg== + tarball: '@parcel/transformer-postcss/-/transformer-postcss-2.0.0-nightly.354.tgz' /@parcel/transformer-posthtml/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2171,6 +2207,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-l9fpjN4vCVdO5DeTaOKNm48/uztGYjtNkKk7tdnTtjcU5K91eM9bp9XSyLeSnGurqhVlRVTZ1RfVzwlz9kz7+w== + tarball: '@parcel/transformer-posthtml/-/transformer-posthtml-2.0.0-nightly.354.tgz' /@parcel/transformer-pug/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2180,6 +2217,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-5GocgtMIEKJ5XCEjTRwd3RkVcPFi9v6oWMU5uhYANDufGtqBlq3tQoEElw63MhEE3HfyEBRymkhxlgnL3fZRKw== + tarball: '@parcel/transformer-pug/-/transformer-pug-2.0.0-nightly.354.tgz' /@parcel/transformer-raw/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2189,6 +2227,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-h0qTgIQlssK51hQgKSGWISJHXAucLXw4vhpcpOTsNjatTnLtIJ0QHK6StOZaYqGmraZrvu++63YzRSiUsvSgPQ== + tarball: '@parcel/transformer-raw/-/transformer-raw-2.0.0-nightly.354.tgz' /@parcel/transformer-react-refresh-babel/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2199,6 +2238,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-kcxCGPM9Gz78m++y1u183o/mZ8/D6PTYAFFott1R6WLMsgonMXxGrF8TCE+/c7XKSGaDWQV8wVegtoVlGVAglw== + tarball: '@parcel/transformer-react-refresh-babel/-/transformer-react-refresh-babel-2.0.0-nightly.354.tgz' /@parcel/transformer-react-refresh-wrap/2.0.0-nightly.354_07ed7636cf5a8f6bbdb71b73c62eef68: dependencies: '@babel/generator': 7.10.5 @@ -2246,6 +2286,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-qr1EIDF7idTjZOzq8q+dDxmHxt9aVKDFsTFHpiSD3U3vVzJKOTz31E67+p390tBHp36XiGyGHWVQDdcWBx+J3g== + tarball: '@parcel/transformer-stylus/-/transformer-stylus-2.0.0-nightly.354.tgz' /@parcel/transformer-sugarss/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2256,6 +2297,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-HZISsw02yq6MLA2klhSaBmQUNq/h7k5OzSvKLBXxjR8S20IRWK6Tl42H+Un6um+lus3Cp6ueA5YjAXbwnKX5Hg== + tarball: '@parcel/transformer-sugarss/-/transformer-sugarss-2.0.0-nightly.354.tgz' /@parcel/transformer-toml/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2264,6 +2306,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-YFJ1NtAlE8kYcTJpu3IScsNTRboCe5/xQLfQ0Q2TfH0jyqaG1/dRhHRzCw7OxMz8SNAdBmtO3/pK4Iq4KpTEow== + tarball: '@parcel/transformer-toml/-/transformer-toml-2.0.0-nightly.354.tgz' /@parcel/transformer-typescript-types/2.0.0-nightly.354_07ed7636cf5a8f6bbdb71b73c62eef68: dependencies: '@parcel/core': 2.0.0-nightly.352_07ed7636cf5a8f6bbdb71b73c62eef68 @@ -2292,6 +2335,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-2dYGPmMU4jcjgdKNtkoHiep5m5QEa+XIjfoTnEyFK3ZjOLDajXr2U/nVD0y3nvbJfFHUrXwZDwjfUYPmC/GaDg== + tarball: '@parcel/transformer-vue/-/transformer-vue-2.0.0-nightly.1976.tgz' /@parcel/transformer-yaml/2.0.0-nightly.354: dependencies: '@parcel/plugin': 2.0.0-nightly.354 @@ -2301,6 +2345,7 @@ packages: parcel: ^2.0.0-alpha.1.1 resolution: integrity: sha512-K8EJyVlFDmoLDhTakhE1k+xPhI3KovkpXS5Bd3snnwnkITfwh+TG/8UXWICjHWxOv6qi2qgth8arCP0YZ18kZw== + tarball: '@parcel/transformer-yaml/-/transformer-yaml-2.0.0-nightly.354.tgz' /@parcel/ts-utils/2.0.0-nightly.354: dependencies: nullthrows: 1.1.1 @@ -2309,10 +2354,12 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-UNo8KZdmaGR4U19iliHE2n4rfuoTC7bZI5x/6L6t0MwvnYJtxGBVIOkwuLo9O+0h8GAmYPS9ZaWQg3KKR6Yl6A== + tarball: '@parcel/ts-utils/-/ts-utils-2.0.0-nightly.354.tgz' /@parcel/types/2.0.0-nightly.354: dev: false resolution: integrity: sha512-TGvHycYpV8p+fmZEu5Aqd8Vnl393RUXFUDe8+6I+iGJ4l6+PJsK1RTyEl9fDvGOwG85dyfCRqlwJpJRpnrEJzg== + tarball: '@parcel/types/-/types-2.0.0-nightly.354.tgz' /@parcel/utils/2.0.0-nightly.354: dependencies: '@iarna/toml': 2.2.5 @@ -2341,6 +2388,7 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-lboE4UmUdAOnALUTjB7klgSGHEKSwLLtbkRK46gubKJZGit6OjguS32F2SvL+Q5KQzufGTv3kwErX/USFUBTGA== + tarball: '@parcel/utils/-/utils-2.0.0-nightly.354.tgz' /@parcel/watcher/2.0.0-alpha.8: dependencies: lint-staged: 10.2.11 @@ -8084,7 +8132,6 @@ packages: resolution: integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw== /p-finally/1.0.0: - dev: true engines: node: '>=4' resolution: @@ -8149,10 +8196,18 @@ packages: node: '>=10' resolution: integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + /p-queue/6.6.0: + dependencies: + eventemitter3: 4.0.4 + p-timeout: 3.2.0 + dev: false + engines: + node: '>=8' + resolution: + integrity: sha512-zPHXPNy9jZsiym0PpJjvnHQysx1fSd/QdaNVwiDRLU2KFChD6h9CkCB6b8i3U8lBwJyA+mHgNZCzcy77glUssQ== /p-timeout/3.2.0: dependencies: p-finally: 1.0.0 - dev: true engines: node: '>=8' resolution: @@ -8196,6 +8251,7 @@ packages: hasBin: true resolution: integrity: sha512-sPgD7MeYeNg4KTSFT629XiE+84kl/MaceM+6hlzfjt07fqHHm3pSFGcV53biCNJ38hNlmYb+DjSaKwJ7vHfy/Q== + tarball: parcel/-/parcel-2.0.0-nightly.352.tgz /parent-module/1.0.1: dependencies: callsites: 3.1.0 @@ -11141,6 +11197,7 @@ packages: dev: false name: befriendlier-shared resolution: + registry: 'https://registry.npmjs.org/' tarball: 'https://codeload.github.com/kararty/befriendlier-shared/tar.gz/d0c50fc044b9d645567d8213258e4f74a48b16b9' version: 5.4.1 github.com/thetutlage/matchit/fae24de689471ddb5e20d2a3bebe88eb80eca86e: @@ -11182,6 +11239,7 @@ specifiers: fsevents: ^2.1.3 got: ^11.5.1 node-sass: ^4.14.1 + p-queue: ^6.6.0 parcel: ^2.0.0-nightly.352 pg: ^8.3.0 phc-argon2: ^1.0.10 diff --git a/providers/AppProvider.ts b/providers/AppProvider.ts index cd3b8a0..9044b94 100644 --- a/providers/AppProvider.ts +++ b/providers/AppProvider.ts @@ -62,7 +62,6 @@ export default class AppProvider { */ if (App.default.environment === 'web') { await import('../start/socket') - await import('../start/initialize') } } } diff --git a/start/initialize.ts b/start/initialize.ts deleted file mode 100644 index 07219fb..0000000 --- a/start/initialize.ts +++ /dev/null @@ -1,6 +0,0 @@ -// import Ws from 'App/Services/Ws' - -export async function joinTwitchChannels () { - // TODO: Tell bots to join Twitch channels. - console.warn('This is not implemented yet!') -} diff --git a/start/socket.ts b/start/socket.ts index 5820a13..c16d340 100644 --- a/start/socket.ts +++ b/start/socket.ts @@ -1,27 +1,49 @@ -import Ws from 'App/Services/Ws' +import Ws, { prettySocketInfo } from 'App/Services/Ws' import Logger from '@ioc:Adonis/Core/Logger' +import { MessageType } from 'befriendlier-shared' Ws.start((socket, request) => { + request.socket.pause() + + const xForwardedFor = request.headers['X-Forwarded-For'] as string | undefined + const remoteAddr = request.socket.remoteAddress + const allow = typeof xForwardedFor !== 'string' || remoteAddr === '127.0.0.1' + + // Kill connections from NON-LOCALHOST sources. TODO: Setup "Trusted Sources" later. + if (!allow) { + Logger.warn(`WEBSOCKET CONNECTION FROM A NON ALLOWED SOURCE! X-Forwarded-For:${String(xForwardedFor)}, remoteAddress:${String(remoteAddr)}`) + socket.terminate() + return + } + const userAgent = request.headers['user-agent'] if (userAgent === undefined) { - Logger.warn(null, - 'Terminating attempted websocket connection from (%s) %s:%s', - request.socket.remoteFamily, - request.socket.remoteAddress, - request.socket.remotePort, - ) - + Logger.warn(`NO USER-AGENT: Connection from ${prettySocketInfo(request.socket)}. TERMINATING.`) socket.terminate() return } - Logger.info('New websocket connection by %s', userAgent) + request.socket.resume() // On a new connection. socket.id = userAgent - socket.isAlive = true socket.connection = request.socket + socket.channels = [] + socket.isAlive = true + + Logger.info(`NEW CONNECTION: [${socket.id}] from ${prettySocketInfo(socket.connection)}.`) + + // eslint-disable-next-line no-void + socket.on('message', (msg) => void Ws.queue.add(async () => await Ws.onMessage(socket, msg))) + + socket.on('close', (code, reason) => Ws.onClose(socket, code, reason)) + + socket.on('error', (error) => Ws.onError(socket, error)) + + // https://github.com/websockets/ws#how-to-detect-and-close-broken-connections + socket.on('pong', (data) => Ws.heartbeat(socket, data)) - socket.on('message', (msg) => Ws.onMessage(socket, msg)) + // Send "welcome" to client. + socket.send(Ws.socketMessage(MessageType.WELCOME, JSON.stringify(''))) }) diff --git a/start/validationRules.ts b/start/validationRules.ts index 4881d1b..24114da 100644 --- a/start/validationRules.ts +++ b/start/validationRules.ts @@ -15,7 +15,7 @@ validator.rule('hexColorString', (value, _, { pointer, arrayExpressionPointer, e * Skip validation when value is not a string. The string * schema rule will handle it */ - if (typeof (value) !== 'string') { + if (typeof value !== 'string') { return } @@ -31,7 +31,7 @@ validator.rule('validTwitchName', (value, _, { pointer, arrayExpressionPointer, * Skip validation when value is not a string. The string * schema rule will handle it */ - if (typeof (value) !== 'string') { + if (typeof value !== 'string') { return }