From bf4cded740ae494ac15cb8fb2c1b4bf782f7b664 Mon Sep 17 00:00:00 2001 From: renan martins Date: Fri, 22 Nov 2024 00:18:53 -0400 Subject: [PATCH] wip: whoknows command --- .gitignore | 288 +++++++++++++++++- .idea/dataSources.xml | 19 ++ .idea/sqldialects.xml | 8 + assets/locales/en/descriptions.json | 9 +- bun.lockb | Bin 66803 -> 68268 bytes docker-compose.yml | 12 +- package.json | 1 + .../migration.sql | 11 + .../migration.sql | 8 + prisma/schema.prisma | 9 + src/commandEngine/command.ts | 1 + src/commandEngine/commands/all/register.ts | 3 +- .../commands/noDMs+registered/whoknows.ts | 39 +++ .../commands/targetable/artist.ts | 4 + src/commandEngine/commands/targetable/cllg.ts | 2 +- src/commandEngine/guards.ts | 7 + src/commandEngine/helpers.ts | 2 +- src/commandEngine/index.ts | 6 + src/databaseEngine/index.ts | 16 + src/fmEngine/completeNowPlaying.ts | 4 + src/fmEngine/index.ts | 21 ++ src/graphEngine/README.md | 3 + src/graphEngine/index.ts | 67 ++++ src/graphEngine/migrations.ts | 64 ++++ src/graphEngine/operations.ts | 156 ++++++++++ src/index.ts | 2 + src/multiplatformEngine/platforms/discord.ts | 28 +- src/multiplatformEngine/platforms/telegram.ts | 5 +- src/utils.ts | 10 +- 29 files changed, 790 insertions(+), 15 deletions(-) create mode 100644 .idea/dataSources.xml create mode 100644 .idea/sqldialects.xml create mode 100644 prisma/migrations/20241122023010_add_fm_display_name/migration.sql create mode 100644 prisma/migrations/20241122023654_add_unique_on_platform_at_display_name/migration.sql create mode 100644 src/commandEngine/commands/noDMs+registered/whoknows.ts create mode 100644 src/graphEngine/README.md create mode 100644 src/graphEngine/index.ts create mode 100644 src/graphEngine/migrations.ts create mode 100644 src/graphEngine/operations.ts diff --git a/.gitignore b/.gitignore index 0828d5e..2b5d8fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,287 @@ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files .env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt dist -lastgram-core -assets/locales/es/* -assets/locales/pt/* -assets/locales/ru/* + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### WebStorm template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# AWS User-specific + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# SonarLint plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### Windows template +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# missing translation files +assets/locales/**/*.missing.json \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..c80d7f0 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,19 @@ + + + + + cassandra + true + com.ing.data.cassandra.jdbc.CassandraDriver + jdbc:cassandra://localhost:9042/lastgram + $ProjectFileDir$ + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/lastgram + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..62ff308 --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/locales/en/descriptions.json b/assets/locales/en/descriptions.json index be82f0d..1db620b 100644 --- a/assets/locales/en/descriptions.json +++ b/assets/locales/en/descriptions.json @@ -10,5 +10,12 @@ "youalbum": "Shows how many times someone has listened to an album you're listening to", "youartist": "Shows how many times someone has listened to an artist you're listening to", "youtrack": "Shows how many times someone has listened to a track you're listening to", - "config": "Shows the configuration panel" + "config": "Shows the configuration panel", + "lyrics": "Shows the lyrics of the song you're listening to", + "acllg": "Asymmetric collages", + "cllg": "Classical collages", + "love": "Loves the song you're listening to", + "mealbum": "Shows how many times you have listened to an album", + "meartist": "Shows how many times you have listened to an artist", + "metrack": "Shows how many times you have listened to a track" } diff --git a/bun.lockb b/bun.lockb index b1f58360fe6e6e2b20a39f093dac0f1f31aa7f63..59beaa0ac83881d222f0cacc14f71deb68980e50 100644 GIT binary patch delta 9740 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 delta 8808 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 SoziJZQX|p0dbuLPl)nOJDcP(5 diff --git a/docker-compose.yml b/docker-compose.yml index 71bc936..22e4566 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,16 @@ services: ports: - '127.0.0.1:6379:6379' + lastgram-scylla: + image: scylladb/scylla + restart: unless-stopped + ports: + - '127.0.0.1:9042:9042' + volumes: + - scylla-lg:/var/lib/scylla + + volumes: pg-lg: - redis-lg: \ No newline at end of file + redis-lg: + scylla-lg: \ No newline at end of file diff --git a/package.json b/package.json index ac4e233..15f9349 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@polka/parse": "^1.0.0-next.0", "@polka/send-type": "^0.5.2", "@prisma/client": "^5.22.0", + "cassandra-driver": "^4.7.2", "country-emoji": "^1.5.6", "date-fns": "^4.1.0", "discord.js": "^14.16.3", diff --git a/prisma/migrations/20241122023010_add_fm_display_name/migration.sql b/prisma/migrations/20241122023010_add_fm_display_name/migration.sql new file mode 100644 index 0000000..8710d74 --- /dev/null +++ b/prisma/migrations/20241122023010_add_fm_display_name/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "FmDisplayName" ( + "id" SERIAL NOT NULL, + "fmUsername" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "platformId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FmDisplayName_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20241122023654_add_unique_on_platform_at_display_name/migration.sql b/prisma/migrations/20241122023654_add_unique_on_platform_at_display_name/migration.sql new file mode 100644 index 0000000..a85d7e5 --- /dev/null +++ b/prisma/migrations/20241122023654_add_unique_on_platform_at_display_name/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[platformId]` on the table `FmDisplayName` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "FmDisplayName_platformId_key" ON "FmDisplayName"("platformId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e3a453a..edf4219 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,15 @@ model User { sessionKey String? } +model FmDisplayName { + id Int @id @default(autoincrement()) + fmUsername String + displayName String + platformId String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + generator client { provider = "prisma-client-js" } diff --git a/src/commandEngine/command.ts b/src/commandEngine/command.ts index 95c16ce..6c9b322 100644 --- a/src/commandEngine/command.ts +++ b/src/commandEngine/command.ts @@ -18,6 +18,7 @@ export interface Command extends MinimalCommand { export interface CommandArgs { name: string required: boolean + everythingAfter?: boolean type?: 'string' | 'integer' | 'boolean' guard?: (arg: string) => boolean parse?: (arg: string) => any diff --git a/src/commandEngine/commands/all/register.ts b/src/commandEngine/commands/all/register.ts index de67bbb..ce8fcbb 100644 --- a/src/commandEngine/commands/all/register.ts +++ b/src/commandEngine/commands/all/register.ts @@ -1,7 +1,7 @@ import { Context } from '../../../multiplatformEngine/common/context.js' import { fixLanguageFormat } from '../../helpers.js' import { client } from '../../../fmEngine/index.js' -import { getUser } from '../../../databaseEngine/index.js' +import { getUser, upsertUserDisplayName } from '../../../databaseEngine/index.js' type Args = { username: string @@ -15,6 +15,7 @@ export default async (ctx: Context, { username }: Args) => { return } await client.user.getInfo(username) + await upsertUserDisplayName(ctx.userPlatformId(), ctx.author.name, username) await ctx.createUserData(username, fixLanguageFormat(ctx.author.languageCode)) ctx.reply(`commands:register.done`, { fmUsername: username }) } diff --git a/src/commandEngine/commands/noDMs+registered/whoknows.ts b/src/commandEngine/commands/noDMs+registered/whoknows.ts new file mode 100644 index 0000000..b8a7222 --- /dev/null +++ b/src/commandEngine/commands/noDMs+registered/whoknows.ts @@ -0,0 +1,39 @@ +import { Context } from '../../../multiplatformEngine/common/context.js' +import { fixLanguageFormat } from '../../helpers.js' +import { client } from '../../../fmEngine/index.js' +import { getUser, upsertUserDisplayName } from '../../../databaseEngine/index.js' +import { graphEngine } from '../../../graphEngine/index.js' +import { hashName } from '../../../utils.js' + +type Args = { + artist: string +} + +export default async (ctx: Context, { artistName }: Args) => { + await graphEngine.addMemberToGroupList(ctx.channel.id, ctx.registeredUserData.fmUsername) + const artist = await client.getArtistInfo(artistName) + if (!artist) { + ctx.reply('commands:whoknows.notFound', { artist }) + return + } + if (!artist.mbid) artist.mbid = hashName(artist.name) + + // try to take the crown + const attempt = await graphEngine.tryToStealCrown(ctx.channel.id, artist.mbid, ctx.registeredUserData.fmUsername) + if (attempt) { + ctx.reply('commands:whoknows.success', { artist }) + return + } else { + ctx.reply('commands:whoknows.failure', { artist }) + return + } +} + +export const info = { + aliases: ['wk', 'coroa', 'crown'], + args: [{ + name: 'artistName', + required: true, + everythingAfter: true + }] +} diff --git a/src/commandEngine/commands/targetable/artist.ts b/src/commandEngine/commands/targetable/artist.ts index 7572a21..4c9734a 100644 --- a/src/commandEngine/commands/targetable/artist.ts +++ b/src/commandEngine/commands/targetable/artist.ts @@ -1,9 +1,13 @@ import { Context } from '../../../multiplatformEngine/common/context.js' import { getNowPlaying } from '../../../fmEngine/completeNowPlaying.js' +import { graphEngine } from '../../../graphEngine/index.js' +import { warn } from '../../../loggingEngine/logging.js' export default async (ctx: Context) => { const data = await getNowPlaying(ctx, 'artist') + if (!data.mbid) warn('commands.artist', `no mbid found for ${data.artist}`) + if (data.playCount && data.playCount > 1 && data.mbid) await graphEngine.upsertScrobbles(ctx.registeredUserData.fmUsername, data.mbid, data.playCount) ctx.reply(`commands:artist`, { user: ctx.targetedUser?.name ?? ctx.registeredUser!.name, isListening: data.isNowPlaying ? 'isPlaying' : 'wasPlaying', diff --git a/src/commandEngine/commands/targetable/cllg.ts b/src/commandEngine/commands/targetable/cllg.ts index 3420cb4..3ce48ec 100644 --- a/src/commandEngine/commands/targetable/cllg.ts +++ b/src/commandEngine/commands/targetable/cllg.ts @@ -98,5 +98,5 @@ const buildComponents = (ctx: MinimalContext, data: ClassicCollageData, id: stri } export const info = { - aliases: [] + aliases: ['collage', 'clg', 'cl'] } diff --git a/src/commandEngine/guards.ts b/src/commandEngine/guards.ts index 577a25f..ebefb6e 100644 --- a/src/commandEngine/guards.ts +++ b/src/commandEngine/guards.ts @@ -92,6 +92,13 @@ export const onlyDMs = (ctx: Context) => { return false } +export const noDMs = (ctx: Context) => { + if (ctx.message.platform === 'telegram' && ctx.channel.id !== ctx.author.id) return true + if (ctx.message.platform === 'discord' && ctx.channel.type !== 'dm') return true + ctx.reply('errors:guards.noDMs') + return false +} + export const developer = (ctx: Context) => { const ids = ['918911149595045959', '205873263258107905', '268526982222970880'] if (ids.includes(ctx.author.id)) return true diff --git a/src/commandEngine/helpers.ts b/src/commandEngine/helpers.ts index 7e9317d..b5a0726 100644 --- a/src/commandEngine/helpers.ts +++ b/src/commandEngine/helpers.ts @@ -19,7 +19,7 @@ export const inferDataFromContent = (content: string): ClassicCollageData => { // check the period let period: '7day' | '1month' | '3month' | '6month' | '12month' | 'overall' = 'overall' - if (['7day', '7days', '7dias', '7d', '1s'].some((a) => content.includes(a))) period = '7day' + if (['7day', '7days', '7dias', '7d', '1s', '1w'].some((a) => content.includes(a))) period = '7day' if (['1month', '1mês', '1mes', '1m'].some((a) => content.includes(a))) period = '1month' if (['3month', '3mês', '3mes', '3m'].some((a) => content.includes(a))) period = '3month' if (['6month', '6mês', '6mes', '6m'].some((a) => content.includes(a))) period = '6month' diff --git a/src/commandEngine/index.ts b/src/commandEngine/index.ts index 5aa3e7d..5866f13 100644 --- a/src/commandEngine/index.ts +++ b/src/commandEngine/index.ts @@ -57,6 +57,12 @@ export class CommandRunner { command.args.forEach((arg, i) => { if (arg.required && !ctx.args[i]) throw new MissingArgumentError(ctx) if (arg.guard && !arg.guard(ctx.args[i])) throw new InvalidArgumentError(ctx) + if (arg.everythingAfter) { + // keep current and all following arguments + args[arg.name] = ctx.args.slice(i).join(' ') + return + } + args[arg.name] = arg.parse ? arg.parse(ctx.args[i]) : ctx.args[i] }) } catch (error) { diff --git a/src/databaseEngine/index.ts b/src/databaseEngine/index.ts index 6b31546..582f70a 100644 --- a/src/databaseEngine/index.ts +++ b/src/databaseEngine/index.ts @@ -37,6 +37,22 @@ export const userExists = (platformId: string) => { }) } +export const upsertUserDisplayName = (platformId: string, displayName: string, username: string) => { + return client.fmDisplayName.upsert({ + where: { + platformId + }, + update: { + displayName + }, + create: { + platformId, + displayName, + fmUsername: username + } + }) +} + process.on('exit', (code) => { debug('databaseEngine.main', `process is exiting with code ${code}, disconnecting from database...`) info('index.main', rainbow('Goodbye!')) diff --git a/src/fmEngine/completeNowPlaying.ts b/src/fmEngine/completeNowPlaying.ts index 46ae2c9..ca1247b 100644 --- a/src/fmEngine/completeNowPlaying.ts +++ b/src/fmEngine/completeNowPlaying.ts @@ -4,11 +4,13 @@ import { Context } from '../multiplatformEngine/common/context.js' import { LastfmRecentTracksTrack } from '@musicorum/lastfm/dist/types/packages/user.js' import { LastfmTag } from '@musicorum/lastfm/dist/types/packages/common.js' import { debug } from '../loggingEngine/logging.js' +import { hashName } from '../utils.js' export type NowPlayingEntity = 'artist' | 'album' | 'track' export interface NowPlayingData { name: string + mbid?: string imageURL: string artist?: string album?: string @@ -53,6 +55,7 @@ export const getNowPlaying = async (ctx: Context, entity: NowPlayingEntity, getF return { name: track.name, + mbid: info.mbid || hashName(track.name), imageURL: info.images?.[3]?.url || track.images[3].url, artist: track.artist.name, album: track.album.name || info.album?.name, @@ -62,3 +65,4 @@ export const getNowPlaying = async (ctx: Context, entity: NowPlayingEntity, getF isNowPlaying: track.nowPlaying || false } } + diff --git a/src/fmEngine/index.ts b/src/fmEngine/index.ts index 57ebe7e..cc0854d 100644 --- a/src/fmEngine/index.ts +++ b/src/fmEngine/index.ts @@ -2,9 +2,16 @@ import { LastClient } from '@musicorum/lastfm' import { LastfmApiMethod } from '@musicorum/lastfm/dist/types/responses.js' import { error } from '../loggingEngine/logging.js' import { newHistogram } from '../loggingEngine/metrics.js' +import { backend } from '../cachingEngine/index.js' type InternalData = Record +interface ReducedArtistInfo { + name: string + mbid?: string + imageURL: string +} + const lastfmRequest = newHistogram('lastfm_request_duration_seconds', 'Duration of last.fm requests in seconds', ['method', 'code', 'success']) class LastgramFMClient extends LastClient { @@ -22,6 +29,20 @@ class LastgramFMClient extends LastClient { error('fmEngine.onRequestFinished', `error while running method ${method} (${response.error}): ${response.message}`) } } + + async getArtistInfo (artist: string): Promise { + const d = await backend?.get(`fm:artist:${artist}`) + // if (d) return Promise.resolve(JSON.parse(d)) + return this.artist.getInfo(artist, { autocorrect: 1 }).then(async (data) => { + const reduced: ReducedArtistInfo = { + name: data.name, + imageURL: data?.images?.[3]?.url || '', + mbid: data.mbid + } + await backend?.setTTL(`fm:artist:${artist}`, JSON.stringify(reduced), 60 * 60 * 12).catch(() => error('fmEngine.getArtistInfo', `error while caching artist info for ${artist}`)) + return reduced + }) + } } export const client = new LastgramFMClient() diff --git a/src/graphEngine/README.md b/src/graphEngine/README.md new file mode 100644 index 0000000..1b3cffd --- /dev/null +++ b/src/graphEngine/README.md @@ -0,0 +1,3 @@ +```bash + +``` \ No newline at end of file diff --git a/src/graphEngine/index.ts b/src/graphEngine/index.ts new file mode 100644 index 0000000..1e48390 --- /dev/null +++ b/src/graphEngine/index.ts @@ -0,0 +1,67 @@ +import { createKeyspace, createTables } from './migrations.js' +import { Client } from 'cassandra-driver' +import { debug, error } from '../loggingEngine/logging.js' +import { getCrown, upsertArtistScrobble, getUserCrowns, tryGetToCrown, addUserToGroupList } from './operations.js' + +const client = new Client({ + contactPoints: ['127.0.0.1'], + localDataCenter: 'datacenter1' +}) + +class GraphEngine { + hasStarted: boolean = false + client: Client | undefined = undefined + + upsertScrobbles (fmUsername: string, artistMbid: string, playCount: number) { + if (!this.hasStarted) { + throw new Error('GraphEngine has not started') + } + debug('graphEngine.upsertScrobbles', `upserting scrobbles for ${fmUsername} on ${artistMbid} with playcount ${playCount}`) + return upsertArtistScrobble(this.client!, fmUsername, artistMbid, playCount) + } + + getCrownOnGroup (groupId: string, artistMbid: string) { + if (!this.hasStarted) { + throw new Error('GraphEngine has not started') + } + return getCrown(this.client!, groupId, artistMbid) + } + + getUserCrowns (groupId: string, fmUsername: string) { + if (!this.hasStarted) { + throw new Error('GraphEngine has not started') + } + return getUserCrowns(this.client!, groupId, fmUsername) + } + + tryToStealCrown (groupId: string, artistMbid: string, fmUsername: string) { + if (!this.hasStarted) { + throw new Error('GraphEngine has not started') + } + return tryGetToCrown(this.client!, groupId, artistMbid, fmUsername) + } + + addMemberToGroupList (groupId: string, fmUsername: string) { + if (!this.hasStarted) { + throw new Error('GraphEngine has not started') + } + return addUserToGroupList(this.client!, groupId, fmUsername).then(() => debug('graphEngine.addMemberToGroupList', `added ${fmUsername} to group ${groupId}`)) + } + + setClient (client: Client) { + this.client = client + this.hasStarted = true + } +} + +export const graphEngine = new GraphEngine() + +export const start = async () => { + await client.connect().then(() => debug('graphEngine.main', 'connected to database')) + await createKeyspace(client).then(() => debug('graphEngine.main', 'keyspace created')).catch((e) => error('graphEngine.main', e.stack)) + client.keyspace = 'lastgram' + + await createTables(client).then(() => debug('graphEngine.main', 'tables created')).catch((e) => error('graphEngine.main', e.stack)) + graphEngine.setClient(client) +} + diff --git a/src/graphEngine/migrations.ts b/src/graphEngine/migrations.ts new file mode 100644 index 0000000..9047964 --- /dev/null +++ b/src/graphEngine/migrations.ts @@ -0,0 +1,64 @@ +import { Client } from 'cassandra-driver' + +export const createKeyspace = async (client: Client) => { + const query = `CREATE KEYSPACE IF NOT EXISTS lastgram WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3};` + await client.execute(query) +} + +// creates the artist scrobble table. has fmUsername, artistMbid and playCount, id, createdAt, updatedAt +// also creates the crown table. references the artist scrobble table and has group id, created at, updated at, id +export const createTables = async (client: Client) => { + const query = ` + CREATE TABLE IF NOT EXISTS artist_scrobbles ( + fmUsername text, + artistMbid text, + playCount int, + id uuid PRIMARY KEY, + createdAt timestamp, + updatedAt timestamp + ); +` + + const query2 = ` + CREATE TABLE IF NOT EXISTS crowns ( + artistScrobbleId uuid, + artistMbid text, + groupId uuid, + fmUsername text, + playCount int, + createdAt timestamp, + updatedAt timestamp, + id uuid PRIMARY KEY + ); + ` + await client.execute(query) + await client.execute(query2) + + // create indexes for artist scrobbles w fmUsername and artistMbid + await client.execute(`CREATE INDEX IF NOT EXISTS ON artist_scrobbles(fmUsername);`) + await client.execute(`CREATE INDEX IF NOT EXISTS ON artist_scrobbles(artistMbid);`) + + // create indexes for crowns w artistScrobbleId and groupId + await client.execute(`CREATE INDEX IF NOT EXISTS ON crowns(artistScrobbleId);`) + await client.execute(`CREATE INDEX IF NOT EXISTS ON crowns(groupId);`) + + // create table for group members + const query3 = ` + CREATE TABLE IF NOT EXISTS group_members ( + groupId text, + fmUsername text, + createdAt timestamp, + updatedAt timestamp, + id uuid PRIMARY KEY + ); + ` + + await client.execute(query3) + + // create indexes for group members w groupId and fmUsername + await client.execute(`CREATE INDEX IF NOT EXISTS ON group_members(groupId);`) + await client.execute(`CREATE INDEX IF NOT EXISTS ON group_members(fmUsername);`) +} + + + diff --git a/src/graphEngine/operations.ts b/src/graphEngine/operations.ts new file mode 100644 index 0000000..cdf0481 --- /dev/null +++ b/src/graphEngine/operations.ts @@ -0,0 +1,156 @@ +import { Client, types } from 'cassandra-driver' +import { debug, error } from '../loggingEngine/logging.js' +import Uuid = types.Uuid + +// returns now date because cassandra-driver is retarded +export const now = () => new Date() + +export const upsertArtistScrobble = async (client: Client, fmUsername: string, artistMbid: string, playCount: number) => { + const itemId = Uuid.random() + const query = ` + INSERT INTO artist_scrobbles (fmUsername, artistMbid, playCount, id, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + IF NOT EXISTS; + ` + const n = now() + const r = await client.execute(query, [fmUsername, artistMbid, playCount, itemId, n, n], { prepare: true }).catch((e) => { + error('graphEngine.upsertArtistScrobble', e.stack) + throw e + }) + + if (r.first()) { + return r.rows[0] + } else { + return await client.execute(` + UPDATE artist_scrobbles + SET playCount = ? AND updatedAt = ? + WHERE fmUsername = ? AND artistMbid = ?; + `, [playCount, fmUsername, artistMbid, now()], { prepare: true }) + } +} + +export const getArtistScrobble = async (client: Client, fmUsername: string, artistMbid: string) => { + const query = ` + SELECT * FROM artist_scrobbles + WHERE fmUsername = ? AND artistMbid = ?; + ` + debug('graphEngine.getArtistScrobble', `getting artist scrobble for ${fmUsername} on ${artistMbid}`) + const r = await client.execute(query, [fmUsername, artistMbid], { prepare: true }) + return r.first() +} + +export const getArtistScrobbleByID = async (client: Client, artistScrobbleId: string) => { + const query = ` + SELECT * FROM artist_scrobbles + WHERE id = ?; + ` + const r = await client.execute(query, [artistScrobbleId], { prepare: true }) + return r.first() +} + +// returns the crown for the given group id, with the artist scrobble data +export const getCrown = async (client: Client, groupId: string, artistMbid: string) => { + const query = ` + SELECT * FROM crowns + WHERE groupId = ? AND artistScrobbleId = ?; + ` + const r = await client.execute(query, [groupId, artistMbid], { prepare: true }) + return r.first() +} + +export const getUserCrowns = async (client: Client, groupId: string, fmUsername: string) => { + const query = ` + SELECT * FROM crowns + WHERE groupId = ? AND fmUsername = ?; + ` + const r = await client.execute(query, [groupId, fmUsername], { prepare: true }) + return r.rows +} + +export const upsertCrown = async (client: Client, groupId: string, artistMbid: string, artistScrobbleId: string, fmUsername: string, playCount: number) => { + const query = ` + INSERT INTO crowns (groupId, artistmbid, artistScrobbleId, fmUsername, playCount, id, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + IF NOT EXISTS; + ` + const n = now() + const id = Uuid.random() + const r = await client.execute(query, [groupId, artistMbid, artistScrobbleId, fmUsername, playCount, id, n, n], { prepare: true }) + if (r.first()) { + return r.rows[0] + } else { + return await client.execute(` + UPDATE crowns + SET playCount = ? AND updatedAt = ? + WHERE groupId = ? AND artistScrobbleId = ?; + `, [playCount, groupId, artistMbid, now()], { prepare: true }) + } +} + +export const updateCrownPlayCount = async (client: Client, crownId: string, playCount: number) => { + return await client.execute(` + UPDATE crowns + SET playCount = ? AND updatedAt = ? + WHERE id = ?; + `, [playCount, crownId, now()], { prepare: true }) +} + +export const tryGetToCrown = async (client: Client, groupId: string, artistMbid: string, fmUsername: string): Promise => { + // first, we must get the fmUser's artist scrobble + const artistScrobble = await getArtistScrobble(client, fmUsername, artistMbid).catch((e) => { + error('graphEngine.tryGetToCrown', 'failed to get artist scrobble: ' + e.stack) + throw e + }) + + if (!artistScrobble) { + return false + } + + // now, we compare the artist scrobble's playCount to the crown's playCount + const crown = await getCrown(client, groupId, artistMbid).catch((e) => { + error('graphEngine.tryGetToCrown', 'failed to get crown: ' + e.stack) + throw e + }) + if (!crown) { + // if it doesn't exist, the user can get the crown + await upsertCrown(client, groupId, artistMbid, artistScrobble.id, fmUsername, artistScrobble.playCount) + return true + } + + if (artistScrobble.playCount > crown.playCount) { + // now, just to be sure, we get the artistscrobbleid from the crown and check if it's still less than fmUser's playCount + const artistScrobbleId = crown.artistScrobbleId + const artistScrobbleFromCrown = await getArtistScrobbleByID(client, artistScrobbleId) + if (artistScrobble.playCount > artistScrobbleFromCrown.playCount) { + // if it is, we give the crown to the other user + await client.execute(` + UPDATE crowns + SET fmUsername = ?, playCount = ?, artistscrobbleid = ?, updatedAt = ? + WHERE id = ?; + `, [fmUsername, artistScrobble.playCount, artistScrobble.id, crown.id, now()], { prepare: true }).catch((e) => { + error('graphEngine.tryGetToCrown', 'failed to update crown: ' + e.stack) + throw e + }) + return true + } else { + // if it's not, we update the play count + await updateCrownPlayCount(client, crown.id, artistScrobble.playCount).catch((e) => { + error('graphEngine.tryGetToCrown', 'failed to update crown: ' + e.stack) + throw e + }) + return false + } + } else { + return false + } +} + +export const addUserToGroupList = async (client: Client, groupId: string, fmUsername: string) => { + const n = now() + const id = Uuid.random() + return await client.execute(` + INSERT INTO group_members (groupId, fmUsername, id, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?) + IF NOT EXISTS; + `, [groupId, fmUsername, id, n, n], { prepare: true }) +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b3f1355..8b2657a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { start as startCommandEngine } from './commandEngine/index.js' import { start as startPlatforms } from './multiplatformEngine/index.js' import { start as startDatabase } from './databaseEngine/index.js' import { start as startInternalServices } from './internalEngine/index.js' +import { start as startGraphEngine } from './graphEngine/index.js' info('index.main', `welcome to ${rainbow('lastgram!')}`) debug('index.main', 'debug messages are enabled') @@ -12,6 +13,7 @@ debug('index.main', 'debug messages are enabled') await startInternalServices() await startCaching() await startDatabase() +await startGraphEngine() await startCommandEngine() await startServer() await startPlatforms() diff --git a/src/multiplatformEngine/platforms/discord.ts b/src/multiplatformEngine/platforms/discord.ts index 8a4bcbd..22734ba 100644 --- a/src/multiplatformEngine/platforms/discord.ts +++ b/src/multiplatformEngine/platforms/discord.ts @@ -7,6 +7,7 @@ import { commandRunner } from '../../commandEngine/index.js' import { buildFromDiscordUser } from '../common/user.js' import { eventEngine } from '../../eventEngine/index.js' import { EngineError } from '../../eventEngine/types/errors.js' +import { updateDiscordCommands } from '../utilities/discord.js' export default class Discord extends Platform { private client: Client @@ -23,6 +24,7 @@ export default class Discord extends Platform { this.client.on('interactionCreate', (...args) => this.onInteraction(...args)) this.createCounter('discord_requests', 'Discord request count', ['success', 'method']) + if (process.env.DISCORD_UPDATE_COMMANDS_ON_START) updateDiscordCommands().then(() => info('discord.main', 'commands updated')) } onReady () { @@ -81,13 +83,33 @@ export default class Discord extends Platform { deliverMessage (ctx: MinimalContext, text: Replyable, interaction: ChatInputCommandInteraction | ButtonInteraction) { if (interaction.isButton()) { - if (ctx.replyOptions?.editOriginal === false) interaction.editReply = interaction.followUp - else interaction.editReply = interaction.update + if (ctx.replyOptions?.editOriginal === false) { + // use follow-up + return interaction.followUp({ + content: text.toString(), + ephemeral: ctx.replyOptions?.ephemeral ?? false, + files: ctx.replyOptions?.imageURL ? [ctx.replyOptions.imageURL] : undefined, + // @ts-ignore + components: ctx.replyOptions?.keepComponents ? undefined : ctx.components.components, + }) + } else { + // use update + return interaction.update({ + content: text.toString(), + // @ts-ignore + components: ctx.replyOptions?.keepComponents ? undefined : ctx.components.components, + files: ctx.replyOptions?.imageURL ? [ctx.replyOptions.imageURL] : undefined + }) + } } + if (ctx.replyOptions?.imageURL) { return interaction.editReply({ content: text.toString(), - files: [ctx.replyOptions.imageURL] + files: [ctx.replyOptions.imageURL], + // @ts-ignore + components: ctx.replyOptions?.keepComponents ? undefined : ctx.components.components, + ephemeral: ctx.replyOptions?.ephemeral ?? false }) } else { return interaction.editReply({ diff --git a/src/multiplatformEngine/platforms/telegram.ts b/src/multiplatformEngine/platforms/telegram.ts index f5d93b1..377f358 100644 --- a/src/multiplatformEngine/platforms/telegram.ts +++ b/src/multiplatformEngine/platforms/telegram.ts @@ -27,8 +27,11 @@ export default class Telegram extends Platform { if (!this.running) return Promise.resolve() return this.request('getUpdates', { - offset + offset, + drop_pending_updates: process.env.DROP_PENDING_UPDATES_ON_START === 'true' }).then(async (response: Record) => { + // set drop pending updates to false now. + process.env.DROP_PENDING_UPDATES_ON_START = 'false' if (!(response instanceof Array)) { warn('platforms.telegram', 'getUpdates did not return an array. waiting 1 second before trying again...') await new Promise(resolve => setTimeout(resolve, 1000)) diff --git a/src/utils.ts b/src/utils.ts index 54a3e30..220af4e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,9 @@ -export const isDevelopment = process.env.NODE_ENV === 'development' || process.env.NODE_ENV !== 'production' +import { createHash } from 'node:crypto' -export const isBun = !!process.versions.bun \ No newline at end of file +export const isDevelopment = process.env.NODE_ENV === 'development' || process.env.NODE_ENV !== 'production' || process.env.DEBUGGING === 'true' + +export const isBun = !!process.versions.bun + +export const hashName = (str: string) => { + return createHash('md5').update(str).digest('hex') +} \ No newline at end of file