diff --git a/CHANGELOG.md b/CHANGELOG.md index dadfc662d..9408dc427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +<<<<<<< HEAD +======= +## [6.38.4-rc.2](https://github.com/consolelabs/mochi-discord/compare/v6.38.4-rc.1...v6.38.4-rc.2) (2023-11-20) + + +### Bug Fixes + +* fix unleash not trigger to redeploy cmds correctly ([#1524](https://github.com/consolelabs/mochi-discord/issues/1524)) ([740bf9a](https://github.com/consolelabs/mochi-discord/commit/740bf9a1b9063698669f43fbea8ae49974b4de20)) + +>>>>>>> preview ## [6.38.4](https://github.com/consolelabs/mochi-discord/compare/v6.38.3...v6.38.4) (2023-11-20) diff --git a/src/adapters/unleash/unleash.ts b/src/adapters/unleash/unleash.ts index 1a1069413..06bac31e9 100644 --- a/src/adapters/unleash/unleash.ts +++ b/src/adapters/unleash/unleash.ts @@ -10,17 +10,20 @@ import { Unleash } from "unleash-client" import { logger } from "logger" export const appName = "mochi" -export let featureData: Record[] = [] -export const unleash = new Unleash({ - url: `${UNLEASH_SERVER_HOST}/api`, - appName: appName, - projectName: UNLEASH_PROJECT, - customHeaders: { Authorization: UNLEASH_API_TOKEN }, -}) - -unleash.on("ready", () => { - logger.info("Unleash READY") -}) +export let unleash: Unleash + +export async function initUnleash() { + unleash = new Unleash({ + url: `${UNLEASH_SERVER_HOST}/api`, + appName: appName, + projectName: UNLEASH_PROJECT, + customHeaders: { Authorization: UNLEASH_API_TOKEN }, + }) + + unleash.on("ready", () => { + logger.info("Unleash READY") + }) +} export async function getProjectFeatures( projectId: string, @@ -105,6 +108,12 @@ export async function getFeatures( const parts = name.split(".") // Extract values from "strategies" constraints with "contextName" as "guildId" environmentData.strategies.forEach((strat) => { + if (featureData[parts[parts.length - 1]]) { + featureData[parts[parts.length - 1]].push( + ...collectGuildIdValues(strat), + ) + return + } featureData[parts[parts.length - 1]] = collectGuildIdValues(strat) }) } diff --git a/src/index.ts b/src/index.ts index 61c1d13c1..521f045cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import Discord from "discord.js" -import { API_SERVER_HOST, DISCORD_TOKEN, PORT } from "./env" +import { API_SERVER_HOST, DEV, DISCORD_TOKEN, PORT } from "./env" import { REST } from "@discordjs/rest" import { logger } from "logger" import { slashCommands } from "commands" @@ -10,12 +10,15 @@ import { run } from "queue/kafka/producer" import { IS_READY } from "listeners/discord/ready" import events from "listeners/discord" import { getTipsAndFacts } from "cache/tip-fact-cache" -import { syncCommands } from "utils/slash-command" +import { registerCommand, syncCommands } from "utils/slash-command" +import { appName, initUnleash, unleash } from "adapters/unleash/unleash" +import { isEqual } from "lodash" export { slashCommands } export let emojis = new Map() let server: Server | null = null +let featureData: Record[] = [] const client = new Discord.Client({ intents: [ @@ -68,7 +71,54 @@ const body = Object.entries(slashCommands ?? {}).map((e) => const rest = new REST({ version: "9" }).setToken(DISCORD_TOKEN) ;(async () => { try { - await syncCommands() + if (DEV) { + await registerCommand() + } else { + await initUnleash() + unleash.on("changed", (stream: any) => { + if (!unleash.isEnabled(`${appName}.discord.cmd.unleash_on_changed`)) { + return + } + + let changed = false + try { + stream.forEach((feature: any) => { + if ( + typeof feature.name !== "string" || + !feature.name.includes(appName) + ) { + return + } + + // if feature data is empty, it means that onchange is triggered by the first time + if (featureData.length === 0) { + changed = true + } + + // assign feature data if feature data is empty to store featureData's stage + if (!featureData[feature.name]) { + featureData[feature.name] = feature + return + } + + if (!isEqual(featureData[feature.name], feature)) { + logger.info("Unleash toggles updated") + changed = true + } + + featureData[feature.name] = feature + }) + } catch (error) { + console.log(error) + } + if (changed) { + syncCommands() + logger.info("Unleash toggles synced") + } else { + logger.info("Unleash toggles not changed") + } + }) + } logger.info("Getting tips and facts.") await getTipsAndFacts() diff --git a/src/utils/slash-command.ts b/src/utils/slash-command.ts index 88ad8bd76..e2c5e364b 100644 --- a/src/utils/slash-command.ts +++ b/src/utils/slash-command.ts @@ -1,15 +1,10 @@ import { slashCommands as slCMDs } from ".." import { Routes } from "discord-api-types/v9" -import { APPLICATION_ID, DISCORD_TOKEN } from "env" +import { APPLICATION_ID, DEV, DISCORD_TOKEN } from "env" import { logger } from "logger" import { SlashCommand } from "types/common" import { REST } from "@discordjs/rest" -import { - unleash, - getFeatures, - appName, - featureData, -} from "adapters/unleash/unleash" +import { getFeatures } from "adapters/unleash/unleash" import mochiAPI from "adapters/mochi-api" import { ModelDiscordCMD, ResponseDiscordGuildResponse } from "types/api" @@ -17,136 +12,295 @@ const rest = new REST({ version: "9" }).setToken(DISCORD_TOKEN) export async function syncCommands() { logger.info("Started refreshing application (/) commands.") - - // Get all guilds from db - let guilds: ResponseDiscordGuildResponse[] | undefined - try { - guilds = (await mochiAPI.getGuilds()).data - } catch (error) { - logger.error(`Failed to get guilds. ${error}`) - return - } - const body = Object.entries(slCMDs ?? {}).map((e) => ({ ...e[1].prepare(e[0]).toJSON(), name: e[0], })) // Filter to global and guild commands - const commands: any[] = [] + let globalCommands: any[] = [] + let guildCommands: Record = {} const featureData = await getFeatures() body.forEach((command) => { if (!featureData.hasOwnProperty(command.name)) { return } - - // If feature flag is empty, add to command to all guilds + // If feature flag is empty, add to command to all guilds by using global commands if (featureData[command.name].length === 0) { - commands.push(command) + globalCommands.push(command) return } + // If feature flag is not empty (that means it has strategy.constrains), add to command to whitelist guilds + featureData[command.name].forEach((guildId) => { + if (!guildCommands[guildId]) { + guildCommands[guildId] = [] + } + guildCommands[guildId].push(command) + }) }) + // Register global commands + await processGlobalCommand(globalCommands) + + // Register guild commands + await processGuildCommand(guildCommands) + + // clean up guild commands that existed in global commands + // Get all guilds from db + await cleanupGuildCommand(globalCommands) + + logger.info("Successfully synced commands!") +} + +export async function registerCommand() { + console.log("Deploying commands to global...") + const body = Object.entries(slCMDs ?? {}).map((e) => ({ + ...e[1].prepare(e[0]).toJSON(), + name: e[0], + })) + await rest + .put(Routes.applicationCommands(APPLICATION_ID), { + body, + }) + .catch(console.log) + console.log("Successfully deployed commands!") +} + +async function cleanupGuildCommand(globalCommands: any[]) { + logger.info("Started cleanup guild commands.") + let guilds: ResponseDiscordGuildResponse[] try { - logger.info("Started refreshing application (/) commands.") - const current = (await rest.get( - Routes.applicationCommands(APPLICATION_ID), - )) as ModelDiscordCMD[] + guilds = (await mochiAPI.getGuilds()).data as ResponseDiscordGuildResponse[] + } catch (error) { + logger.error(`Failed to get guilds. ${error}`) + return + } + + await Promise.all( + guilds.map(async (guild) => { + if (!guild?.id) { + return + } - if (current.length < commands.length) { - logger.info("Started adding application (/) commands.") + let current: ModelDiscordCMD[] = [] try { - const resp = await fetch( - `https://discord.com/api/v9/applications/${APPLICATION_ID}/commands`, - { - method: "PUT", - headers: { - Authorization: `Bot ${DISCORD_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(commands), - }, + current = (await rest.get( + Routes.applicationGuildCommands(APPLICATION_ID, guild.id), + )) as ModelDiscordCMD[] + } catch (error) { + logger.error( + `Failed to get guild commands for guild ${guild.id}. ${error}`, ) - const json = await resp.json() - // check if json is array - if (!Array.isArray(json)) { - console.log(json) + return + } + + const commandsToDelete = current.filter((c) => + globalCommands.some((cmd) => cmd.name == c.name), + ) + + await Promise.all( + commandsToDelete.map(async (command) => { + if (command && command.id) { + logger.info( + `Deleting guild command {${command.id} ${command.name}} which is a global command from guild ${guild.id}`, + ) + try { + await rest.delete( + Routes.applicationGuildCommand( + APPLICATION_ID, + String(guild.id), + command.id, + ), + ) + } catch (error) { + logger.error( + `Failed to delete guild command {${command.id} ${command.name}} which is a global command from guild ${guild.id}. ${error}`, + ) + } + } + }), + ) + }), + ) +} + +async function processGuildCommand(guildCommands: Record) { + await Promise.all( + Object.keys(guildCommands).map(async (guildId) => { + const commands = guildCommands[guildId] + + let current: ModelDiscordCMD[] = [] + try { + let current = (await rest.get( + Routes.applicationGuildCommands(APPLICATION_ID, guildId), + )) as ModelDiscordCMD[] + } catch (error) { + logger.error( + `Failed to get guild commands for guild ${guildId}. ${error}`, + ) + return + } + + logger.info( + `Started refreshing application guild (/) commands for guild: ${guildId}, current: [${current + .flat() + .map((c) => c.name) + .join(", ")}], commands: [${commands + .flat() + .map((c) => c.name) + .join(", ")}]`, + ) + + try { + logger.info("Started adding application (/) guild commands.") + const commandsToAdd = commands.filter((c) => { + const command = current.find((cmd) => cmd.name === c.name) + return !command + }) + if (commandsToAdd.length > 0) { + const resp = await fetch( + `https://discord.com/api/v9/applications/${APPLICATION_ID}/guilds/${guildId}/commands`, + { + method: "PUT", + headers: { + Authorization: `Bot ${DISCORD_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(commandsToAdd), + }, + ) + + const json = await resp.json() + + if (!Array.isArray(json)) { + logger.error( + `Failed to add application (/) guild commands for guild: ${guildId}. resp: ${json}`, + ) + return + } + + logger.info( + `Successfully added application (/) guild commands for guild: ${guildId}. commands: [${commandsToAdd + .flat() + .map((c: SlashCommand) => c.name) + .join(", ")}]`, + ) } } catch (err) { - console.log(err) + logger.error( + `Failed to add application (/) guild commands for guild: ${guildId}. ${err}`, + ) } - return - } - if (current.length > commands.length) { - const promises: Promise[] = [] - current.forEach((cmd) => { - const command = commands.find((c) => c.name === cmd.name) - if (!command && cmd.id) { - promises.push( - new Promise((resolve, reject) => { + try { + logger.info( + `Started deleting application (/) guild commands for guild: ${guildId}`, + ) + const promises = current + .filter((cmd) => { + const command = commands.find((c) => c.name === cmd.name) + return command && cmd.id + }) + .map((cmd) => { + return new Promise((resolve, reject) => { if (cmd.id) { rest - .delete(Routes.applicationCommand(APPLICATION_ID, cmd.id)) + .delete( + Routes.applicationGuildCommand( + APPLICATION_ID, + guildId, + cmd.id, + ), + ) .then(() => { - logger.info(`Deleted command ${cmd.id} ${cmd.name}`) + logger.info( + `Deleted command ${cmd.id} ${cmd.name} from guild ${guildId}`, + ) resolve() }) .catch((error) => { reject(error) }) } - }), - ) - } - }) - await Promise.all(promises) + }) + }) + await Promise.all(promises) + } catch (error) { + logger.error( + `Failed to refresh application guild (/) commands for guild: ${guildId}. ${error}`, + ) + } + }), + ) +} + +async function processGlobalCommand(globalCommands: any[]) { + logger.info("Started refreshing application (/) global commands.") + const current = (await rest.get( + Routes.applicationCommands(APPLICATION_ID), + )) as ModelDiscordCMD[] + + const commandsToAdd = globalCommands.filter((c) => { + const command = current.find((cmd) => cmd.name === c.name) + return !command + }) + if (commandsToAdd.length > 0) { + logger.info("Started adding application (/) global commands.") + try { + const resp = await fetch( + `https://discord.com/api/v9/applications/${APPLICATION_ID}/commands`, + { + method: "PUT", + headers: { + Authorization: `Bot ${DISCORD_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(globalCommands), + }, + ) + const json = await resp.json() + // check if json is array + if (!Array.isArray(json)) { + console.log(json) + logger.error( + `Failed to add application (/) global commands. resp: ${json}`, + ) + } + } catch (err) { + logger.error(`Failed to add application (/) global commands. err: ${err}`) } - } catch (error) { - await logger.error(`Failed to register application (/) commands. ${error}`) - return } - await logger.info( - `Successfully reloaded application (/) commands. commands: [${commands + const promises = current + .filter((cmd) => { + const command = globalCommands.find((c) => c.name === cmd.name) + return !command && cmd.id + }) + .map((cmd) => { + return new Promise((resolve, reject) => { + if (cmd.id) { + rest + .delete(Routes.applicationCommand(APPLICATION_ID, cmd.id)) + .then(() => { + logger.info(`Deleted command ${cmd.id} ${cmd.name}`) + resolve() + }) + .catch((error) => { + logger.error( + `Failed to delete command ${cmd.id} ${cmd.name}. ${error}`, + ) + reject(error) + }) + } + }) + }) + await Promise.all(promises) + + logger.info( + `Successfully reloaded application (/) global commands. commands: [${globalCommands + .flat() .map((c) => c.name) .join(", ")}]`, ) - - return } - -unleash.on("changed", (stream) => { - if (!unleash.isEnabled("mochi.discord.cmd.use_global_cmd")) { - return - } - - let changed = false - try { - stream.forEach((feature: any) => { - if (!String(feature.name).includes(appName)) { - return - } - if (!featureData[feature.name]) { - featureData[feature.name] = feature - return - } - if ( - JSON.stringify(featureData[feature.name]) !== JSON.stringify(feature) - ) { - logger.info("Unleash toggles updated") - changed = true - } - featureData[feature.name] = feature - }) - } catch (error) { - console.log(error) - } - if (changed) { - syncCommands() - logger.info("Unleash toggles synced") - } else { - logger.info("Unleash toggles not changed") - } -})