diff --git a/config/default.json b/config/default.json index 742b8c4d7..d2f7a762e 100644 --- a/config/default.json +++ b/config/default.json @@ -255,6 +255,7 @@ "workerActivity": "PoracleJS Helper", // Worker Activity "disableAutoGreetings": false, // whether Poracle will greet users who are assigned a role and get permission to use the bot "uploadEmbedImages": false, // whether Poracle will retrieve and upload images direct to discord CDN + "dynamicRsvpEditing": true, // whether Poracle will edit existing raid messages when RSVP counts change instead of sending new messages // checkRole - whether to check all discord users for role membership (every _checkRoleInterval_ // hours, deleting them if not (note general.roleCheckMode must be set to // actually carry out the deletions rather than just logging them) diff --git a/src/app.js b/src/app.js index 8af68ca22..8965ced45 100644 --- a/src/app.js +++ b/src/app.js @@ -180,10 +180,14 @@ function handleShutdown() { const workerSaves = [] for (const worker of discordWorkers) { workerSaves.push(worker.saveTimeouts()) + workerSaves.push(worker.saveRaidCache()) } if (telegram) workerSaves.push(telegram.saveTimeouts()) if (telegramChannel) workerSaves.push(telegramChannel.saveTimeouts()) - if (discordWebhookWorker) workerSaves.push(discordWebhookWorker.saveTimeouts()) + if (discordWebhookWorker) { + workerSaves.push(discordWebhookWorker.saveTimeouts()) + workerSaves.push(discordWebhookWorker.saveRaidCache()) + } gymCache.save(true) Promise.all(workerSaves) diff --git a/src/controllers/raid.js b/src/controllers/raid.js index 80756db47..aef76e5df 100644 --- a/src/controllers/raid.js +++ b/src/controllers/raid.js @@ -472,6 +472,8 @@ class Raid extends Controller { const templateType = 'raid' const message = await this.createMessage(logReference, templateType, platform, cares.template, language, cares.ping, view) + const raidMessageKey = `${data.gym_id}:${data.end}:${data.pokemon_id}:${cares.type}:${cares.id}` + const work = { lat: data.latitude.toString() .substring(0, 8), @@ -486,6 +488,10 @@ class Raid extends Controller { emoji: data.emoji, logReference, language, + raidMessageKey, + gymId: data.gym_id, + raidEndTime: data.end, + pokemonId: data.pokemon_id, } jobs.push(work) } @@ -599,6 +605,8 @@ class Raid extends Controller { const templateType = 'egg' const message = await this.createMessage(logReference, templateType, platform, cares.template, language, cares.ping, view) + const raidMessageKey = `${data.gym_id}:${data.end}:${data.pokemon_id}:${cares.type}:${cares.id}` + const work = { lat: data.latitude.toString().substring(0, 8), lon: data.longitude.toString().substring(0, 8), @@ -611,6 +619,10 @@ class Raid extends Controller { emoji: data.emoji, logReference, language, + raidMessageKey, + gymId: data.gym_id, + raidEndTime: data.end, + pokemonId: data.pokemon_id, } jobs.push(work) } diff --git a/src/lib/discord/commando/commands/rsvp-clean.js b/src/lib/discord/commando/commands/rsvp-clean.js new file mode 100644 index 000000000..064611311 --- /dev/null +++ b/src/lib/discord/commando/commands/rsvp-clean.js @@ -0,0 +1,48 @@ +exports.run = async (client, msg) => { + try { + // Check target + if (!client.config.discord.admins.includes(msg.author.id) && msg.channel.type !== 'DM') { + return await msg.author.send(client.translator.translate('Please run commands in Direct Messages')) + } + + const startMessage = await msg.reply('Clearing raid message cache - this will force all future RSVP updates to send new messages instead of editing existing ones...') + + // Clear raid message caches for all discord workers + let clearedCount = 0 + for (const worker of client.discordWorkers) { + const keys = worker.raidMessageCache.keys() + clearedCount += keys.length + worker.raidMessageCache.flushAll() + await worker.saveRaidCache() + } + + // Clear raid message cache for webhook worker + if (client.discordWebhookWorker) { + const keys = client.discordWebhookWorker.raidMessageCache.keys() + clearedCount += keys.length + client.discordWebhookWorker.raidMessageCache.flushAll() + await client.discordWebhookWorker.saveRaidCache() + } + + await startMessage.delete() + const finishMessage = await msg.reply(`Raid message cache cleared! Removed ${clearedCount} cached raid messages. All future RSVP updates will send new messages.`) + setTimeout(() => { finishMessage.delete() }, 15000) + } catch (err) { + await msg.reply('Failed to run rsvp-clean, check logs') + client.logs.log.error(`rsvp-clean command "${msg.content}" unhappy:`, err) + } +} + +exports.conf = { + enabled: true, + guildOnly: false, + aliases: [], + permLevel: 1, +} + +exports.help = { + name: 'rsvp-clean', + description: 'Clear raid message cache (admin only)', + usage: 'rsvp-clean', + example: '!rsvp-clean', +} diff --git a/src/lib/discord/discordWebhookWorker.js b/src/lib/discord/discordWebhookWorker.js index 18c99af46..d0eb82e75 100644 --- a/src/lib/discord/discordWebhookWorker.js +++ b/src/lib/discord/discordWebhookWorker.js @@ -21,6 +21,7 @@ class DiscordWebhookWorker { this.webhookQueue = [] this.rehydrateTimeouts = rehydrateTimeouts this.webhookTimeouts = new NodeCache() + this.raidMessageCache = new NodeCache() this.query = query this.queueProcessor = new FairPromiseQueue(this.webhookQueue, this.config.tuning.concurrentDiscordWebhookConnections, ((t) => t.target)) @@ -107,6 +108,70 @@ class DiscordWebhookWorker { const logReference = data.logReference ? data.logReference : 'Unknown' + // Check if this is an RSVP update for an existing raid message + if (data.raidMessageKey && this.config.discord.dynamicRsvpEditing) { + const existingMsg = this.raidMessageCache.get(data.raidMessageKey) + if (existingMsg && existingMsg.messageId) { + this.logs.discord.info(`${logReference}: http(s)> ${data.name} WEBHOOK Editing raid message (RSVP update)`) + try { + const editUrl = `${data.target}/messages/${existingMsg.messageId}` + const timeoutMs = this.config.tuning.discordTimeout || 10000 + + const res = await this.retrySender(`${logReference} (edit)`, async () => { + let uploadData = data.message + let headers = null + + if (this.config.discord.uploadEmbedImages && data.message.embeds && data.message.embeds.length && data.message.embeds[0].image && data.message.embeds[0].image.url) { + const copyMessage = JSON.parse(JSON.stringify(data.message)) + const imageUrl = copyMessage.embeds[0].image.url + copyMessage.embeds[0].image.url = 'attachment://map.png' + + const response = await axios.get(imageUrl, { responseType: 'arraybuffer' }) + const buffer = Buffer.from(response.data, 'utf-8') + + const formData = new FormData() + formData.append('payload_json', JSON.stringify(copyMessage)) + formData.append('file', buffer, 'map.png') + + headers = formData.getHeaders() + uploadData = formData + } + + const source = axios.CancelToken.source() + const timeout = setTimeout(() => { + source.cancel(`Timeout waiting for response - ${timeoutMs}ms`) + }, timeoutMs) + + const result = await axios({ + method: 'patch', + url: editUrl, + data: uploadData, + headers, + validateStatus: ((status) => status < 500), + cancelToken: source.token, + }) + + clearTimeout(timeout) + return result + }) + + if (res.status >= 200 && res.status <= 299) { + this.logs.discord.info(`${logReference}: ${data.name} WEBHOOK Edit successful (status ${res.status})`) + return true + } else if (res.status === 404) { + this.logs.discord.warn(`${logReference}: ${data.name} WEBHOOK Message not found (404), will send new message`) + this.raidMessageCache.del(data.raidMessageKey) + } else { + this.logs.discord.warn(`${logReference}: ${data.name} WEBHOOK Edit failed (status ${res.status}), will send new message`) + this.raidMessageCache.del(data.raidMessageKey) + } + } catch (editErr) { + this.logs.discord.warn(`${logReference}: ${data.name} WEBHOOK Failed to edit message, will send new message`, editErr) + this.raidMessageCache.del(data.raidMessageKey) + } + } + } + this.logs.discord.info(`${logReference}: http(s)> ${data.name} WEBHOOK Sending discord message`) this.logs.discord.debug(`${logReference}: http(s)> ${data.name} WEBHOOK Sending discord message to ${data.target}`, data.message) @@ -169,6 +234,20 @@ class DiscordWebhookWorker { } this.logs.discord.silly(`${logReference}: ${data.name} WEBHOOK results ${data.target} ${res.statusText} ${res.status}`, res.headers) + // Store message ID for future RSVP edits + if (data.raidMessageKey && this.config.discord.dynamicRsvpEditing && res.status === 200) { + const msgId = res.data.id + const ttl = Math.max((data.raidEndTime * 1000 - Date.now()) / 1000 + 600, 60) + this.raidMessageCache.set(data.raidMessageKey, { + messageId: msgId, + targetType: 'webhook', + webhookUrl: data.target, + gymId: data.gymId, + endTime: data.raidEndTime, + pokemonId: data.pokemonId, + }, ttl) + } + if (data.clean && res.status === 200) { const msgId = res.data.id this.webhookTimeouts.set(msgId, data.target, Math.floor(msgDeletionMs / 1000) + 1) @@ -197,7 +276,14 @@ class DiscordWebhookWorker { async saveTimeouts() { // eslint-disable-next-line no-underscore-dangle this.webhookTimeouts._checkData(false) - return fsp.writeFile('.cache/cleancache-webhookWorker.json', JSON.stringify(this.webhookTimeouts.data), 'utf8') + const cleanPromise = fsp.writeFile('.cache/cleancache-webhookWorker.json', JSON.stringify(this.webhookTimeouts.data), 'utf8') + + // Also save raid cache + // eslint-disable-next-line no-underscore-dangle + this.raidMessageCache._checkData(false) + const raidPromise = fsp.writeFile('.cache/raidmessage-webhookWorker.json', JSON.stringify(this.raidMessageCache.data), 'utf8') + + return Promise.all([cleanPromise, raidPromise]) } async deleteMessage(logReference, hookName, hookUrl, msgId) { @@ -274,6 +360,37 @@ class DiscordWebhookWorker { this.logs.log.info(`Error processing historic deletes ${err}`) } } + + // Also load raid cache + await this.loadRaidCache() + } + + async loadRaidCache() { + let loaddatatxt + + try { + loaddatatxt = await fsp.readFile('.cache/raidmessage-webhookWorker.json', 'utf8') + } catch { + return + } + + const now = Date.now() + + let data + try { + data = JSON.parse(loaddatatxt) + } catch { + this.logs.log.warn('Raid message cache for webhookWorker contains invalid data - ignoring') + return + } + + for (const key of Object.keys(data)) { + const msgData = data[key] + if (msgData.t > now) { + const newTtl = Math.floor((msgData.t - now) / 1000) + this.raidMessageCache.set(key, msgData.v, newTtl) + } + } } } diff --git a/src/lib/discord/discordWorker.js b/src/lib/discord/discordWorker.js index cbc2d016e..9d4137428 100644 --- a/src/lib/discord/discordWorker.js +++ b/src/lib/discord/discordWorker.js @@ -22,6 +22,7 @@ class Worker { this.client = {} this.rehydrateTimeouts = rehydrateTimeouts this.discordMessageTimeouts = new NodeCache() + this.raidMessageCache = new NodeCache() this.discordQueue = [] this.queueProcessor = new FairPromiseQueue(this.discordQueue, this.config.tuning.concurrentDiscordDestinationsPerBot, ((entry) => entry.target)) this.status = statusActivity.status @@ -141,6 +142,41 @@ class Worker { try { const logReference = data.logReference ? data.logReference : 'Unknown' + // Check if this is an RSVP update for an existing raid message + if (data.raidMessageKey && this.config.discord.dynamicRsvpEditing) { + const existingMsg = this.raidMessageCache.get(data.raidMessageKey) + if (existingMsg && existingMsg.messageId) { + this.logs.discord.info(`${logReference}: #${this.id} -> ${data.name} ${data.target} USER Editing raid message (RSVP update)`) + try { + const channel = user || await this.client.users.fetch(data.target) + const dmChannel = await channel.createDM() + const message = await dmChannel.messages.fetch(existingMsg.messageId) + + if (this.config.discord.uploadEmbedImages && data.message.embed && data.message.embed.image && data.message.embed.image.url) { + const { url } = data.message.embed.image + data.message.embed.image.url = 'attachment://map.png' + data.message.files = [{ attachment: url, name: 'map.png' }] + } + + const startTime = performance.now() + if (data.message.embed) { + data.message.embeds = [data.message.embed] + delete data.message.embed + } + + await message.edit(data.message) + const endTime = performance.now(); + this.logs.discord.info(`${logReference}: #${this.id} -> ${data.name} ${data.target} USER Edit successful (${(endTime - startTime).toFixed(1)} ms)`) + + return true + } catch (editErr) { + this.logs.discord.warn(`${logReference}: #${this.id} Failed to edit message ${existingMsg.messageId}, will send new message`, editErr) + // Fall through to send new message + this.raidMessageCache.del(data.raidMessageKey) + } + } + } + this.logs.discord.info(`${logReference}: #${this.id} -> ${data.name} ${data.target} USER Sending discord message${data.clean ? ' (clean)' : ''}`) if (!user) { @@ -166,6 +202,19 @@ class Worker { const endTime = performance.now(); (this.config.logger.timingStats ? this.logs.discord.verbose : this.logs.discord.debug)(`${logReference}: #${this.id} -> ${data.name} ${data.target} USER (${endTime - startTime} ms)`) + // Store message ID for future RSVP edits + if (data.raidMessageKey && this.config.discord.dynamicRsvpEditing) { + const ttl = Math.max((data.raidEndTime * 1000 - Date.now()) / 1000 + 600, 60) // raid end + 10 min + this.raidMessageCache.set(data.raidMessageKey, { + messageId: msg.id, + targetType: 'user', + targetId: data.target, + gymId: data.gymId, + endTime: data.raidEndTime, + pokemonId: data.pokemonId, + }, ttl) + } + if (data.clean) { setTimeout(async () => { try { @@ -189,6 +238,45 @@ class Worker { try { const logReference = data.logReference ? data.logReference : 'Unknown' + // Check if this is an RSVP update for an existing raid message + if (data.raidMessageKey && this.config.discord.dynamicRsvpEditing) { + const existingMsg = this.raidMessageCache.get(data.raidMessageKey) + if (existingMsg && existingMsg.messageId) { + this.logs.discord.info(`${logReference}: #${this.id} -> ${data.name} ${data.target} CHANNEL Editing raid message (RSVP update)`) + try { + const channel = await this.client.channels.fetch(data.target) + if (!channel) { + this.logs.discord.warn(`${logReference}: #${this.id} -> ${data.name} ${data.target} CHANNEL not found`) + this.raidMessageCache.del(data.raidMessageKey) + } else { + const message = await channel.messages.fetch(existingMsg.messageId) + + if (this.config.discord.uploadEmbedImages && data.message.embed && data.message.embed.image && data.message.embed.image.url) { + const { url } = data.message.embed.image + data.message.embed.image.url = 'attachment://map.png' + data.message.files = [{ attachment: url, name: 'map.png' }] + } + + const startTime = performance.now() + if (data.message.embed) { + data.message.embeds = [data.message.embed] + delete data.message.embed + } + + await message.edit(data.message) + const endTime = performance.now(); + this.logs.discord.info(`${logReference}: #${this.id} -> ${data.name} ${data.target} CHANNEL Edit successful (${(endTime - startTime).toFixed(1)} ms)`) + + return true + } + } catch (editErr) { + this.logs.discord.warn(`${logReference}: #${this.id} Failed to edit channel message ${existingMsg.messageId}, will send new message`, editErr) + // Fall through to send new message + this.raidMessageCache.del(data.raidMessageKey) + } + } + } + this.logs.discord.info(`${logReference}: #${this.id} -> ${data.name} ${data.target} CHANNEL Sending discord message${data.clean ? ' (clean)' : ''}`) const channel = await this.client.channels.fetch(data.target) const msgDeletionMs = ((data.tth.days * 86400) + (data.tth.hours * 3600) + (data.tth.minutes * 60) + data.tth.seconds) * 1000 + this.config.discord.messageDeleteDelay @@ -210,6 +298,19 @@ class Worker { const endTime = performance.now(); (this.config.logger.timingStats ? this.logs.discord.verbose : this.logs.discord.debug)(`${logReference}: #${this.id} -> ${data.name} ${data.target} CHANNEL (${endTime - startTime} ms)`) + // Store message ID for future RSVP edits + if (data.raidMessageKey && this.config.discord.dynamicRsvpEditing) { + const ttl = Math.max((data.raidEndTime * 1000 - Date.now()) / 1000 + 600, 60) // raid end + 10 min + this.raidMessageCache.set(data.raidMessageKey, { + messageId: msg.id, + targetType: 'channel', + targetId: data.target, + gymId: data.gymId, + endTime: data.raidEndTime, + pokemonId: data.pokemonId, + }, ttl) + } + if (data.clean) { setTimeout(async () => { try { @@ -280,7 +381,14 @@ class Worker { // eslint-disable-next-line no-underscore-dangle this.discordMessageTimeouts._checkData(false) - return fsp.writeFile(`.cache/cleancache-discord-${this.client.user.tag}.json`, JSON.stringify(this.discordMessageTimeouts.data), 'utf8') + const cleanPromise = fsp.writeFile(`.cache/cleancache-discord-${this.client.user.tag}.json`, JSON.stringify(this.discordMessageTimeouts.data), 'utf8') + + // Also save raid cache + // eslint-disable-next-line no-underscore-dangle + this.raidMessageCache._checkData(false) + const raidPromise = fsp.writeFile(`.cache/raidmessage-discord-${this.client.user.tag}.json`, JSON.stringify(this.raidMessageCache.data), 'utf8') + + return Promise.all([cleanPromise, raidPromise]) } async loadTimeouts() { @@ -329,6 +437,37 @@ class Worker { this.logs.log.info(`Error processing historic deletes ${err}`) } } + + // Also load raid cache + await this.loadRaidCache() + } + + async loadRaidCache() { + let loaddatatxt + + try { + loaddatatxt = await fsp.readFile(`.cache/raidmessage-discord-${this.client.user.tag}.json`, 'utf8') + } catch { + return + } + + const now = Date.now() + + let data + try { + data = JSON.parse(loaddatatxt) + } catch { + this.logs.log.warn(`Raid message cache for discord tag ${this.client.user.tag} contains invalid data - ignoring`) + return + } + + for (const key of Object.keys(data)) { + const msgData = data[key] + if (msgData.t > now) { + const newTtl = Math.floor((msgData.t - now) / 1000) + this.raidMessageCache.set(key, msgData.v, newTtl) + } + } } }