Stripped repo down to bare minimum

This commit is contained in:
Noah Heague
2021-09-05 13:32:28 +10:00
parent 71cb8e3578
commit 3699856b6b
13 changed files with 73 additions and 451 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules
.idea
.vscode
yarn-error.log
+62
View File
@@ -0,0 +1,62 @@
# Contributing
A basic overview of code-style & preferred methods of doing things.
If you have any questions or suggestions, please create a ticket. We are open to suggestions.
# Styleguides
## Git commit messages
- Be explicit about what you changed. ("Fixed bug" vs "Fixed bug relating to incorrect import")
- reference issue numbers if possible ("Fixed incorrect env var as reported in #3")
## Code preferences
These are not strictly necessary however it is nice to have a codebase where everyone's code looks the same
Verboseness is preferred, be explicit with your types and interfaces.
If you use an object more than once or pass it around, create an interface or type.
### Enums
enums are preferred due to their nicer accessor patterns as well as making potential refactors easier.
```ts
/** Preferred */
export enum LoggerLayers {
client = "client",
slashCommands = "slashCommands"
}
const logger = createLayeredLogger(LoggerLayers.client)
```
```ts
/** Allowed but not preferred */
export type loggerLayer = "client" | "server"
const logger = createLayeredLogger("client")
```
### Prettier
Prettier is not installed due to its habit of making code look terrible. We trust that contributors will format their code to match the style in the codebase. A new developer should not be able to tell code apart by contributor.
Potentially at some-point ESLint rules may be configured to further enforce code style however as it is now, We trust contributors will format their code to match the style in the codebase.
### ESLint
Eslint should be run on save and before committing.
### Try & Catch
Functions where any form of complex actions occur should be wrapped in try catch blocks. Errors preferably must be handled.
```ts
const myComplexFunc = async (): myReturnValue => {
try {
logger.info("Describe whats happening");
const foo = await callNetworkingFunction("bar", "baz");
return foo.zoo
} catch (e) {
/** if 'e' is more complex handle different error messages */
logger.error("Describe what went wrong", e);
} finally {
logger.info("Describe what succeeded");
}
}
```
### Tabs vs Spaces
Tabs, ESLint enforces this.
+2 -21
View File
@@ -1,25 +1,6 @@
# Music Linkr
# Tachi-Bot
Automatically translate streaming service links.
> [Invite the bot](https://discord.com/api/oauth2/authorize?client_id=686462477956808742&permissions=0&scope=applications.commands%20bot)
> [Join the Server](https://discord.gg/a5a7NQV)
## Platform Support
This bot supports every platform the [song.link](https://song.link) platform supports as it takes advantage of its API.
Currently, it detects the following URL Schemes:
- Spotify
- Apple Music
- Deezer
- Soundcloud
- Google Music
- Youtube (Prepend links with `?`)
![Apple Music](https://i.imgur.com/lwFDkUS.png)
Integrate with Tachi Services
## Support & Maintenance
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "music_linkr",
"name": "tachi-bot",
"version": "1.0.0",
"main": "src/index.ts",
"license": "MIT",
"license": "AGPL3",
"scripts": {
"start": "ts-node src/index.ts"
},
+1 -40
View File
@@ -1,4 +1,4 @@
// noinspection JSUnusedGlobalSymbols
/** @TODO Potentially re-work this to only run on PROD env, handy for DEV env! **/
/** @deprecated replace with database call & cache */
export const allowedChannels = [
"876321394005254214"
@@ -6,44 +6,5 @@ export const allowedChannels = [
export enum LoggerLayers {
client = "client",
songLink = "songLink",
embedGenerator = "embedGenerator",
deezerMetadata = "deezerMetadata",
slashCommands = "slashCommands"
}
export const platformRegex = [
"open.spotify.com",
"play.google.com/music/m",
"music.apple.com",
"soundcloud.com",
"deezer.com",
"^\\?(https?://)?(www.)?(youtube.com|youtu.?be)/.+$",
];
export const platformData: Record<string, {emoji: string, prettyName: string}> = {
spotify: {
emoji: "686481957730779146",
prettyName: "Spotify",
},
soundcloud: {
emoji: "686481619405766706",
prettyName: "Soundcloud",
},
google: {
emoji: "686482178464284674",
prettyName: "Google Play",
},
appleMusic: {
emoji: "686482030120140886",
prettyName: "Apple Music",
},
youtube: {
emoji: "689044959290196020",
prettyName: "Youtube",
},
deezer: {
emoji: "699195540180566026",
prettyName: "Deezer",
},
};
-135
View File
@@ -1,135 +0,0 @@
import { EmbedFieldData, MessageActionRow, MessageButton, MessageEmbed, ReplyMessageOptions } from "discord.js";
import { LoggerLayers, platformData } from "../config";
import { SongLinkData, SongLinkDataLinks } from "../getSongLink/getSongLink";
import { createLayeredLogger } from "../utils/logger";
const logger = createLayeredLogger(LoggerLayers.embedGenerator);
const generateEmbedFields = (data: SongLinkData): EmbedFieldData[] => {
const fields = [];
try {
if (data.metaData.genres) {
fields.push({
name: "Genres",
value: data.metaData.genres.length ? data.metaData.genres.join(", ") : "Unknown?"
});
}
if (data.metaData.releaseYear) {
fields.push({
name: "Release Date",
value: data.metaData.releaseYear || "Unknown?"
});
}
} catch (e) {
logger.error("Unable to generate embed fields", e);
}
return fields;
};
const createEmbed = (data: SongLinkData): MessageEmbed => {
logger.info(`Creating embed for ${data.metaData.title}`);
try {
return new MessageEmbed({
title: data.metaData.title || "Unknown?",
description: data.metaData.artistName || "Unknown?",
thumbnail: {
url: data.metaData.artwork
},
fields: generateEmbedFields(data)
});
} catch (e) {
logger.error("Unable to create embed", e);
}
};
type IterableSongLinkDataLinks = Record<string, SongLinkDataLinks>
const createPlatformButton = (links: IterableSongLinkDataLinks, item: string) => {
logger.verbose(`Creating button for ${platformData[item].prettyName}`);
try {
return new MessageButton({
label: platformData[item].prettyName,
disabled: false,
url: links[item].url,
style: "LINK",
}).setEmoji(platformData[item].emoji);
} catch (e) {
logger.error("Unabe to create platform button", e);
}
};
const createComponents = (data: SongLinkData): MessageActionRow[] => {
logger.info(`Creating components for ${data.metaData.title}`);
try {
const itemsPerRow = 2;
const links: IterableSongLinkDataLinks = data.links;
const objectKeys = Object.keys(data.links).filter(key => platformData[key]);
const rowCount = Math.ceil(objectKeys.length / itemsPerRow);
const allRows = [];
for (let row = 0; row < rowCount; row++) {
const buttonRow = new MessageActionRow();
const offset = itemsPerRow * row;
const itemsInRow = objectKeys.slice(offset, offset + itemsPerRow);
buttonRow.addComponents(
itemsInRow.map(item => {
return createPlatformButton(links, item);
}));
allRows.push(buttonRow);
}
// Append the "View more" button
const lastRow = allRows.length - 1;
if (allRows[lastRow].components.length < itemsPerRow) {
allRows[lastRow].components.push(
new MessageButton({
label: "View more",
disabled: false,
url: data.moreUrl,
style: "LINK"
}));
} else {
allRows.push(new MessageActionRow({
components: [
new MessageButton({
label: "View more",
disabled: false,
url: data.moreUrl,
style: "LINK"
})
]
}));
}
return allRows;
} catch (e) {
logger.error("Unable to generate components", e);
}
}
;
export const createLinkReply = (data: SongLinkData): ReplyMessageOptions => {
logger.info(`Creating message for ${data.metaData.title}`);
try {
const embed = createEmbed(data);
const components = createComponents(data);
return {
content: " ",
embeds: [embed],
components: components
};
} catch (e) {
logger.error("Unable to create reply:", e);
}
};
-89
View File
@@ -1,89 +0,0 @@
import { SonglinkResponse } from "songlink-api/lib/types/Response";
import { getDetailedMetadata, getIdFromEntity } from "./getSongLink";
interface MockSonglinkResponse extends Omit<Omit<SonglinkResponse, "linksByPlatform">, "entitiesByUniqueId"> {
linksByPlatform: {
deezer?: {
entityUniqueId: string;
url: string;
};
appleMusic?: {
entityUniqueId: string;
url: string;
};
}
}
describe("getIdFromEntity", () => {
test("Should return a valid entity id", () => {
expect(getIdFromEntity("DEEZER::uniqueSnowflake")).toEqual("uniqueSnowflake");
});
});
const mockSongData: MockSonglinkResponse = {
entityUniqueId: "DEEZER_SONG::17128360",
userCountry: "",
pageUrl: "",
linksByPlatform: {
"deezer": {
entityUniqueId: "DEEZER_SONG::17128360",
url: "",
}
}
};
const mockAlbumData: MockSonglinkResponse = {
entityUniqueId: "DEEZER_ALBUM::1602407",
userCountry: "",
pageUrl: "",
linksByPlatform: {
"deezer": {
entityUniqueId: "DEEZER_ALBUM::1602407",
url: "",
}
}
};
const nonDeezerMockAlbumData: MockSonglinkResponse = {
entityUniqueId: "ITUNES_ALBUM::1150202719",
userCountry: "",
pageUrl: "",
linksByPlatform: {
"appleMusic": {
entityUniqueId: "ITUNES_ALBUM::1150202719",
url: "",
}
}
};
const nonDeezerMockSongData: MockSonglinkResponse = {
entityUniqueId: "ITUNES_SONG::1150202854",
userCountry: "",
pageUrl: "",
linksByPlatform: {
"appleMusic": {
entityUniqueId: "ITUNES_SONG::1150202854",
url: "",
}
}
};
describe("getDetailedMetadata", () => {
test("Should fetch detailed metadata for a song", async () => {
expect(await getDetailedMetadata(mockSongData as SonglinkResponse))
.toHaveProperty("releaseYear", "2005-09-06");
});
test("Should fetch detailed metadata for an album", async () => {
expect(await getDetailedMetadata(mockAlbumData as SonglinkResponse))
.toHaveProperty("releaseYear", "2005-09-06");
});
test("Should fail nicely when fetching a non-deezer album", async () => {
expect(await getDetailedMetadata(nonDeezerMockAlbumData as SonglinkResponse))
.toEqual(undefined);
});
test("Should fail nicely when fetching a non-deezer song", async () => {
expect(await getDetailedMetadata(nonDeezerMockSongData as SonglinkResponse))
.toEqual(undefined);
});
});
-79
View File
@@ -1,79 +0,0 @@
import { getLinks } from "songlink-api";
import { SonglinkPlatform } from "songlink-api/lib/types/Platform";
import { SonglinkResponse } from "songlink-api/lib/types/Response";
import { LoggerLayers } from "../config";
import { createLayeredLogger } from "../utils/logger";
import { getDeezerMetadata } from "./metadata/deezer";
const logger = createLayeredLogger(LoggerLayers.songLink);
export interface MetaData {
title?: string;
artistName?: string;
artwork?: string;
genres?: string[]
releaseYear?: string;
}
export interface SongLinkDataLinks {
entityUniqueId: string;
url: string;
nativeAppUriMobile?: string;
nativeAppUriDesktop?: string;
}
export interface SongLinkData {
links: {
[P in SonglinkPlatform]: SongLinkDataLinks
}
moreUrl: string;
metaData: MetaData;
}
export const getIdFromEntity = (entity: string): string => {
try {
return entity.split("::")[1];
} catch (e) {
logger.error("Unable to getIdFromEntity:", e);
}
};
export const getDetailedMetadata = async (data: SonglinkResponse): Promise<MetaData> => {
if (data.linksByPlatform.deezer) {
return await getDeezerMetadata(data);
} else {
logger.warn("Requested metaData but it is not on any supported streaming services");
}
};
export const getSongLinkResponse = async (url: string): Promise<SongLinkData> => {
logger.info("Fetching songLink");
try {
const rawData = await getLinks({ url: url }, { apiKey: process.env.SONGLINKAPIKEY });
const firstEntity = rawData.entitiesByUniqueId[Object.keys(rawData.entitiesByUniqueId)[0]];
const basicMetadata: MetaData = {
title: firstEntity.title,
artistName: firstEntity.artistName,
artwork: firstEntity.thumbnailUrl
};
const detailedMetadata = await getDetailedMetadata(rawData);
return {
links: rawData.linksByPlatform,
moreUrl: rawData.pageUrl,
metaData: {
title: detailedMetadata?.title || basicMetadata.title,
artistName: detailedMetadata?.artistName || basicMetadata.artistName,
artwork: detailedMetadata?.artwork || basicMetadata.artwork,
genres: detailedMetadata?.genres,
releaseYear: detailedMetadata?.releaseYear
}
};
} catch (e) {
logger.error("Unable to fetch songLink response:", e);
}
};
-52
View File
@@ -1,52 +0,0 @@
import * as deezerApi from "deezer-api-ts";
import { SonglinkResponse } from "songlink-api/lib/types/Response";
import { LoggerLayers } from "../../config";
import { createLayeredLogger } from "../../utils/logger";
import { getIdFromEntity, MetaData } from "../getSongLink";
const logger = createLayeredLogger(LoggerLayers.deezerMetadata);
export const getDeezerMetadata = async (data: SonglinkResponse): Promise<MetaData> => {
try {
logger.info("Fetching metadata from Deezer");
const entityId = getIdFromEntity(data.linksByPlatform.deezer.entityUniqueId);
let metaData;
if (data.linksByPlatform.deezer.entityUniqueId.includes("SONG")) {
const songMetaData = await deezerApi.getTrack(parseInt(entityId));
let albumMetadata;
if (songMetaData.album.id) {
albumMetadata = await deezerApi.getAlbum(songMetaData.album.id);
}
metaData = {
title: songMetaData.title,
artist: songMetaData.artist,
artwork: songMetaData.album?.cover || "",
genres: albumMetadata?.genres || undefined,
release_date: albumMetadata?.release_date || undefined
};
} else {
metaData = await deezerApi.getAlbum(parseInt(entityId));
}
if (!metaData.title) {
logger.warn("Attempted to fetch metadata from deezer but received empty response");
return;
}
logger.info(`Successfully fetched metadata for ${metaData.title}`);
return {
title: metaData.title,
artistName: metaData.artist.name,
artwork: metaData.cover,
genres: metaData.genres?.data.map(genre => genre.name),
releaseYear: metaData.release_date
};
} catch (e) {
logger.error("Unable to fetch metadata from Deezer:", e);
}
};
+1 -1
View File
@@ -1,5 +1,5 @@
import { CommandInteraction } from "discord.js";
export const help = async (interaction: CommandInteraction): Promise<void> => {
await interaction.reply("Send a link to a streaming service and I will reply with a rich embed.\nPrepend youtube links with `?`\n\nGithub: https://github.com/Puffycheeses/music_linkr\nDiscord: https://discord.gg/a5a7NQV");
await interaction.reply("This is an example help command");
};
+2 -26
View File
@@ -1,10 +1,7 @@
import { Client, Intents } from "discord.js";
import { LoggerLayers, platformRegex } from "./config";
import { createLinkReply } from "./createEmbed/createEmbed";
import { getSongLinkResponse } from "./getSongLink/getSongLink";
import { LoggerLayers } from "./config";
import { registerSlashCommands, slashCommands, tidyGuildCommands, SlashCommand } from "./slashCommands/register";
import { createLayeredLogger } from "./utils/logger";
import { shouldReply } from "./utils/utils";
const logger = createLayeredLogger(LoggerLayers.client);
@@ -16,30 +13,9 @@ const client = new Client({
]
});
client.on("messageCreate", async (message) => {
try {
if (shouldReply(message)) {
if (new RegExp(platformRegex.join("|")).test(message.content)) {
logger.info("Received valid message");
const services = message.content.match(/\bhttps?:\/\/\S+/gi);
for (let i = 0; i < services.length; i++) {
const data = await getSongLinkResponse(services[i]);
await message.channel.send(createLinkReply(data));
}
logger.info("Complete \n");
}
}
} catch (e) {
logger.error("messageCreate Error:", e);
}
return;
});
client.on("interactionCreate", async (interaction) => {
if (!interaction.isCommand()) return;
try {
const command = slashCommands.find((command: SlashCommand) => {
return command.info.name === interaction.commandName;
+2 -1
View File
@@ -22,7 +22,7 @@ export const slashCommands: SlashCommand[] = [
{
info: new SlashCommandBuilder()
.setName("help")
.setDescription("Shows information about Music Linkr")
.setDescription("Shows information about this bot")
.toJSON(),
exec: async (interaction: CommandInteraction) => await help(interaction),
}
@@ -45,6 +45,7 @@ export const registerSlashCommands = async (client: Client): Promise<void> => {
}
};
/** @TODO Potentially re-work this to only run on PROD env, handy for DEV env! **/
/** @deprecated Remove once no guilds have legacy commands */
export const tidyGuildCommands = async (client: Client): Promise<void> => {
try {
-5
View File
@@ -1,5 +0,0 @@
import { Message } from "discord.js";
export const shouldReply = (message: Message): boolean =>
message.author.id !== "876320894295887944" &&
!message.author.bot;