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 b1f5836..59beaa0 100644 Binary files a/bun.lockb and b/bun.lockb differ 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