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