mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-30 10:58:07 +03:00
Stripped repo down to bare minimum
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
.idea
|
||||
.vscode
|
||||
yarn-error.log
|
||||
|
||||
@@ -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
@@ -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 `?`)
|
||||
|
||||

|
||||
Integrate with Tachi Services
|
||||
|
||||
## Support & Maintenance
|
||||
|
||||
|
||||
+2
-2
@@ -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
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Message } from "discord.js";
|
||||
|
||||
export const shouldReply = (message: Message): boolean =>
|
||||
message.author.id !== "876320894295887944" &&
|
||||
!message.author.bot;
|
||||
Reference in New Issue
Block a user