diff --git a/config/defaults/dts.json b/config/defaults/dts.json index bb7555ba8..96b136d27 100644 --- a/config/defaults/dts.json +++ b/config/defaults/dts.json @@ -321,6 +321,25 @@ ] } }, + { + "id": 1, + "language": "en", + "type": "fort-update", + "default": true, + "platform": "discord", + "template": { + "embed": { + "title": "{{changeTypeText}} {{fortTypeText}} {{name}}", + "description": "{{#if description}}*{{description}}*\n\n{{/if}}{{#if isEditDescription}}**Old Description**\n> {{oldDescription}}\n\n**New Description**\n> {{newDescription}}\n\n{{/if}}{{#if isEditName}}### Old Name\n> {{oldName}}\n\n### New Name\n> {{newName}}\n\n{{/if}}\nMaps: [Google]({{{mapurl}}}) | [Apple]({{{applemap}}})", + "thumbnail": { + "url": "{{{imgUrl}}}" + }, + "image": { + "url": "{{{staticMap}}}" + } + } + } + }, { "id": 1, "language": "en", diff --git a/config/defaults/testdata.json b/config/defaults/testdata.json index 79108f1e9..cdc20770e 100644 --- a/config/defaults/testdata.json +++ b/config/defaults/testdata.json @@ -153,5 +153,10 @@ "type": "pokestop", "test": "sparklylure", "webhook": {"name":"Memorial Sculpture & Verse","pokestop_id":"0d4b26adbf24446ba893f0aa3f8de337.16","latitude":51.284727,"longitude":1.060694,"updated":1643554276,"last_modified":1643553312,"lure_expiration":1643555112,"lure_id":506,"url":"http://lh3.googleusercontent.com/Ce-yPhm1FwGNbFzaM0ToMESZ57EWx1g8dKXXgEE4o-mmdP0XdsHk2nSEXOBk88H5PIXBHud-b7m6PUUJ3N8Pe_XB98NY"} + }, + { + "type": "fort_update", + "test": "edit", + "webhook": {"change_type": "edit", "edit_types": ["name", "description"], "new": {"id": "f7430347f5c34facb838be376f16adea.16", "type": "gym", "name": "Journey Through Trees And Time", "description": "A beautiful trip through time, trees, and space", "image_url": "http://lh3.googleusercontent.com/9kWjcud4Eeh4nNC6jv8zBkEr8kuQbW-QY2sKRHQqLrwnxf3drxO5AtR2qsDEjmxz3-h_eTHnQrT3AFl5Xmg2MXVqChh5", "location": {"lat": 51.268716, "lon": 1.013956}}, "old": {"id": "f7430347f5c34facb838be376f16adea.16", "type": "gym", "name": "The old walkway", "description": "An ancient trip through time and space", "image_url": "http://lh3.googleusercontent.com/9kWjcud4Eeh4nNC6jv8zBkEr8kuQbW-QY2sKRHQqLrwnxf3drxO5AtR2qsDEjmxz3-h_eTHnQrT3AFl5Xmg2MXVqChh5", "location": {"lat": 51.268716, "lon": 1.013956}}} } ] diff --git a/src/app.js b/src/app.js index e34b1c210..068bf2cc1 100644 --- a/src/app.js +++ b/src/app.js @@ -646,6 +646,19 @@ async function processOne(hook) { } break } + case 'fort_update': { + const fortId = hook.message.new?.id ?? hook.message.old?.id + if (config.general.disableFortUpdate) { + fastify.controllerLog.debug(`${fortId}: Fort update was received but set to be ignored in config`) + break + } + if (!hook.message.poracleTest) { + fastify.webhooks.info(`fort_update ${JSON.stringify(hook.message)}`) + // Caching not relevant, no duplicates + } + await processHook(hook) + break + } case 'quest': { if (config.general.disableQuest) { fastify.controllerLog.debug(`${hook.message.pokestop_id}: Quest was received but set to be ignored in config`) diff --git a/src/controllerWorker.js b/src/controllerWorker.js index d951ddc3a..a219a4f50 100644 --- a/src/controllerWorker.js +++ b/src/controllerWorker.js @@ -27,6 +27,7 @@ const MonsterController = require('./controllers/monster') const RaidController = require('./controllers/raid') const QuestController = require('./controllers/quest') const PokestopController = require('./controllers/pokestop') +const FortUpdateController = require('./controllers/fortupdate') const GymController = require('./controllers/gym') const PokestopLureController = require('./controllers/pokestop_lure') const NestController = require('./controllers/nest') @@ -59,6 +60,7 @@ const questController = new QuestController(logs.controller, knex, cachingGeocod const pokestopController = new PokestopController(logs.controller, knex, cachingGeocoder, scannerQuery, config, dts, geofence, GameData, rateLimitedUserCache, translatorFactory, mustache, controllerWeatherManager, statsData, eventParsers) const nestController = new NestController(logs.controller, knex, cachingGeocoder, scannerQuery, config, dts, geofence, GameData, rateLimitedUserCache, translatorFactory, mustache, controllerWeatherManager, statsData, eventParsers) const pokestopLureController = new PokestopLureController(logs.controller, knex, cachingGeocoder, scannerQuery, config, dts, geofence, GameData, rateLimitedUserCache, translatorFactory, mustache, controllerWeatherManager, statsData, eventParsers) +const fortUpdateController = new FortUpdateController(logs.controller, knex, cachingGeocoder, scannerQuery, config, dts, geofence, GameData, rateLimitedUserCache, translatorFactory, mustache, controllerWeatherManager, statsData, eventParsers) const gymController = new GymController(logs.controller, knex, cachingGeocoder, scannerQuery, config, dts, geofence, GameData, rateLimitedUserCache, translatorFactory, mustache, controllerWeatherManager, statsData, eventParsers) const monsterAlarmMatch = new MonsterAlarmMatch(logs.controller, knex, config) @@ -113,6 +115,15 @@ async function processOne(hook) { } break } + case 'fort_update': { + const result = await fortUpdateController.handle(hook.message) + if (result) { + queueAddition = result + } else { + log.error(`Worker ${workerId}: Missing result from ${hook.type} processor`, { data: hook.message }) + } + break + } case 'quest': { const result = await questController.handle(hook.message) if (result) { @@ -187,6 +198,7 @@ function reloadDts() { nestController.setDts(newDts) pokestopLureController.setDts(newDts) gymController.setDts(newDts) + fortUpdateController.setDts(newDts) log.info('DTS reloaded') } catch (err) { log.error('Error reloading dts', err) @@ -203,6 +215,7 @@ function reloadGeofence() { nestController.setGeofence(newGeofence) pokestopLureController.setGeofence(newGeofence) gymController.setGeofence(newGeofence) + fortUpdateController.setGeofence(newGeofence) log.info('Geofence reloaded') } catch (err) { log.error('Error reloading geofence', err) diff --git a/src/controllers/fortupdate.js b/src/controllers/fortupdate.js new file mode 100644 index 000000000..1b3898f80 --- /dev/null +++ b/src/controllers/fortupdate.js @@ -0,0 +1,303 @@ +const geoTz = require('geo-tz') +const moment = require('moment-timezone') +const Controller = require('./controller') + +/** + * Controller for processing nest webhooks + */ +class FortUpdate extends Controller { + async fortUpdateWhoCares(obj) { + const data = obj + const { areastring, strictareastring } = this.buildAreaString(data.matched) + + let changestring = '1 = 0 ' + data.changeTypes.forEach((change) => { + changestring = changestring.concat(`or forts.change_types like '%"${change}"%' `) + }) + + let query = ` + select humans.id, humans.name, humans.type, humans.language, humans.latitude, humans.longitude, forts.template, forts.distance, forts.ping from forts + join humans on (humans.id = forts.id and humans.current_profile_no = forts.profile_no) + where humans.enabled = 1 and humans.admin_disable = false and (humans.blocked_alerts IS NULL OR humans.blocked_alerts NOT LIKE '%forts%') and + ((forts.fort_type = 'everything' or forts.fort_type = '${data.fortType}') and (forts.change_types = '[]' or (${changestring})) + ${data.isEmpty ? 'and forts.include_empty = 1' : ''}) + ${strictareastring} + ` + + if (['pg', 'mysql'].includes(this.config.database.client)) { + query = query.concat(` + and + ( + ( + round( + 6371000 + * acos(cos( radians(${data.latitude}) ) + * cos( radians( humans.latitude ) ) + * cos( radians( humans.longitude ) - radians(${data.longitude}) ) + + sin( radians(${data.latitude}) ) + * sin( radians( humans.latitude ) ) + ) + ) < forts.distance and forts.distance != 0) + or + ( + forts.distance = 0 and (${areastring}) + ) + ) + `) + // group by humans.id, humans.name, humans.type, humans.language, humans.latitude, humans.longitude, invasion.template, invasion.distance, invasion.clean, invasion.ping + } else { + query = query.concat(` + and ((nests.distance = 0 and (${areastring})) or nests.distance > 0) + `) + // group by humans.id, humans.name, humans.type, humans.language, humans.latitude, humans.longitude, invasion.template, invasion.distance, invasion.clean, invasion.ping + } + // this.log.silly(`${data.pokestop_id}: Query ${query}`) + + let result = await this.db.raw(query) + if (!['pg', 'mysql'].includes(this.config.database.client)) { + result = result.filter((res) => res.distance === 0 || res.distance > 0 && res.distance > this.getDistance({ lat: res.latitude, lon: res.longitude }, { lat: data.latitude, lon: data.longitude })) + } + result = this.returnByDatabaseType(result) + // remove any duplicates + const alertIds = [] + result = result.filter((alert) => { + if (!alertIds.includes(alert.id)) { + alertIds.push(alert.id) + return alert + } + }) + return result + } + + async handle(obj) { + const data = obj + // const minTth = this.config.general.alertMinimumTime || 0 + + try { + data.id = data.old?.id || data.new?.id + const logReference = data.id + + data.longitude = data.new?.location?.lon || data.old?.location?.lon + data.latitude = data.new?.location?.lat || data.old?.location?.lat + + Object.assign(data, this.config.general.dtsDictionary) + data.googleMapUrl = `https://maps.google.com/maps?q=${data.latitude},${data.longitude}` + data.appleMapUrl = `https://maps.apple.com/maps?daddr=${data.latitude},${data.longitude}` + data.wazeMapUrl = `https://www.waze.com/ul?ll=${data.latitude},${data.longitude}&navigate=yes&zoom=17` + if (this.config.general.rdmURL) { + data.rdmUrl = `${this.config.general.rdmURL}${!this.config.general.rdmURL.endsWith('/') ? '/' : ''}@${data.latitude}/@${data.longitude}/18` + } + if (this.config.general.reactMapURL) { + data.reactMapUrl = `${this.config.general.reactMapURL}${!this.config.general.reactMapURL.endsWith('/') ? '/' : ''}id/nests/${data.nest_id}` + } + if (this.config.general.rocketMadURL) { + data.rocketMadUrl = `${this.config.general.rocketMadURL}${!this.config.general.rocketMadURL.endsWith('/') ? '/' : ''}?lat=${data.latitude}&lon=${data.longitude}&zoom=18.0` + } + data.name = this.escapeJsonString(data.name) + + const nestExpiration = data.reset_time + (7 * 24 * 60 * 60) + data.tth = moment.preciseDiff(Date.now(), nestExpiration * 1000, true) + data.disappearDate = moment(nestExpiration * 1000).tz(geoTz.find(data.latitude, data.longitude)[0].toString()).format(this.config.locale.date) + data.resetDate = moment(data.reset_time * 1000).tz(geoTz.find(data.latitude, data.longitude)[0].toString()).format(this.config.locale.date) + data.disappearTime = moment(nestExpiration * 1000).tz(geoTz.find(data.latitude, data.longitude)[0].toString()).format(this.config.locale.time) + data.resetTime = moment(data.reset_time * 1000).tz(geoTz.find(data.latitude, data.longitude)[0].toString()).format(this.config.locale.time) + + data.applemap = data.appleMapUrl // deprecated + data.mapurl = data.googleMapUrl // deprecated + data.distime = data.disappearTime // deprecated + + // Stop handling if it already disappeared or is about to go away + // if (data.tth.firstDateWasLater || ((data.tth.days * 24 * 3600) + (data.tth.hours * 3600) + (data.tth.minutes * 60) + data.tth.seconds) < minTth) { + // this.log.debug(`${data.pokestop_id} Nest already disappeared or is about to go away in: ${data.tth.days}d ${data.tth.hours}:${data.tth.minutes}:${data.tth.seconds}`) + // return [] + // } + + data.matchedAreas = this.pointInArea([data.latitude, data.longitude]) + data.matched = data.matchedAreas.map((x) => x.name.toLowerCase()) + + data.fortType = data.new?.type || data.old?.type || 'unknown' + // If this is a change from an empty fort (eg after a GMO), treat it as 'new' in poracle + if (data.change_type === 'edit' && !(data.old?.name || data.old?.description)) { + data.change_type = 'new' + data.edit_types = null + } + + data.changeTypes = [] + if (data.edit_types) data.changeTypes.push(...data.edit_types) + data.changeTypes.push(data.change_type) + data.isEmpty = !(data.new?.name || data.new?.description || data.old?.name) + + // clean everything + + if (data.new) { + if (data.new.name) data.new.name = this.escapeJsonString(data.new.name) + if (data.new.description) data.new.description = this.escapeJsonString(data.new.description) + } + + if (data.old) { + if (data.old.name) data.new.name = this.escapeJsonString(data.old.name) + if (data.old.description) data.new.description = this.escapeJsonString(data.old.description) + } + + // helpers + + data.isEdit = data.change_type === 'edit' + data.isNew = data.change_type === 'new' + data.isRemoval = data.change_type === 'removal' + + data.isEditLocation = data.changeTypes.includes('location') + data.isEditName = data.changeTypes.includes('name') + data.isEditDescription = data.changeTypes.includes('description') + data.isEditImageUrl = data.changeTypes.includes('image_url') + data.isEditImgUrl = data.isEditImageUrl + + data.oldName = data.old?.name ?? '' + data.oldDescription = data.old?.description ?? '' + data.oldImageUrl = data.old?.image_url ?? '' + data.oldImgUrl = data.oldImageUrl + data.oldLatitude = data.old?.location?.lat || 0.0 + data.oldLongitude = data.old?.location?.lon || 0.0 + + data.newName = data.new?.name ?? '' + data.newDescription = data.new?.description ?? '' + data.newImageUrl = data.new?.image_url ?? '' + data.newImgUrl = data.newImageUrl + data.newLatitude = data.new?.location?.lat || 0.0 + data.newLongitude = data.new?.location?.lon || 0.0 + + data.fortTypeText = data.fortType === 'pokestop' ? 'Pokestop' : 'Gym' + // eslint-disable-next-line default-case + switch (data.change_type) { + case 'edit': + data.changeTypeText = 'Edit' + break + case 'removal': + data.changeTypeText = 'Removal' + break + case 'new': + data.changeTypeText = 'New' + break + } + + data.name = data.new?.name || data.old?.name || 'unknown' + data.name = this.escapeJsonString(data.name) + data.description = data.new?.description || data.old?.description || 'unknown' + data.imgUrl = data.new?.image_url || data.old?.image_url || '' + + if (data.old) { + data.old.imgUrl = data.old.image_url + data.old.imageUrl = data.old.image_url + } + if (data.new) { + data.new.imgUrl = data.new.image_url + data.new.imageUrl = data.new.image_url + } + + const whoCares = data.poracleTest ? [{ + ...data.poracleTest, + clean: false, + ping: '', + }] : await this.fortUpdateWhoCares(data) + + if (whoCares.length) { + this.log.info(`${logReference}: Fort Update ${data.fortType} ${data.id} ${data.name} found in areas (${data.matched}) and ${whoCares.length} humans cared.`) + } else { + this.log.verbose(`${logReference}: Fort Update ${data.fortType} ${data.id} ${data.name} found in areas (${data.matched}) and ${whoCares.length} humans cared.`) + } + + let discordCacheBad = true // assume the worst + whoCares.forEach((cares) => { + if (!this.isRateLimited(cares.id)) discordCacheBad = false + }) + + if (discordCacheBad) { + whoCares.forEach((cares) => { + this.log.verbose(`${logReference}: Not creating nest alert (Rate limit) for ${cares.type} ${cares.id} ${cares.name} ${cares.language} ${cares.template}`) + }) + + return [] + } + + data.shinyPossible = this.shinyPossible.isShinyPossible(data.pokemonId, data.formId) + + data.stickerUrl = data.imgUrl + + const geoResult = await this.getAddress({ lat: data.latitude, lon: data.longitude }) + const jobs = [] + + // Attempt to calculate best position for map + const markers = [] + if (data.old?.location?.lat) { + markers.push({ latitude: data.old.location.lat, longitude: data.old.location.lon }) + } + if (data.new?.location?.lat) { + markers.push({ latitude: data.new.location.lat, longitude: data.new.location.lon }) + } + + const position = this.tileserverPregen.autoposition({ + markers, + }, 500, 250) + data.zoom = Math.min(position.zoom, 16) + data.map_longitude = position.longitude + data.map_latitude = position.latitude + + await this.getStaticMapUrl(logReference, data, 'fort-update', ['map_latitude', 'map_longitude', 'zoom', 'imgUrl', 'isEditLocation', 'oldLatitude', 'oldLongitude', 'newLatitude', 'newLongitude']) + data.staticmap = data.staticMap // deprecated + + for (const cares of whoCares) { + this.log.debug(`${logReference}: Creating fort update alert for ${cares.id} ${cares.name} ${cares.type} ${cares.language} ${cares.template}`, cares) + + const rateLimitTtr = this.getRateLimitTimeToRelease(cares.id) + if (rateLimitTtr) { + this.log.verbose(`${logReference}: Not creating fort update (Rate limit) for ${cares.type} ${cares.id} ${cares.name} Time to release: ${rateLimitTtr}`) + // eslint-disable-next-line no-continue + continue + } + this.log.verbose(`${logReference}: Creating fort update alert for ${cares.type} ${cares.id} ${cares.name} ${cares.language} ${cares.template}`) + + const language = cares.language || this.config.general.locale + // const translator = this.translatorFactory.Translator(language) + let [platform] = cares.type.split(':') + if (platform === 'webhook') platform = 'discord' + + const view = { + ...geoResult, + ...data, + time: data.distime, + tthd: data.tth.days, + tthh: data.tth.hours, + tthm: data.tth.minutes, + tths: data.tth.seconds, + now: new Date(), + nowISO: new Date().toISOString(), + areas: data.matchedAreas.filter((area) => area.displayInMatches).map((area) => area.name).join(', '), + } + + const templateType = 'fort-update' + const message = await this.createMessage(logReference, templateType, platform, cares.template, language, cares.ping, view) + + const work = { + lat: data.latitude.toString().substring(0, 8), + lon: data.longitude.toString().substring(0, 8), + message, + target: cares.id, + type: cares.type, + name: cares.name, + tth: data.tth, + clean: false, + emoji: data.emoji, + logReference, + language, + } + + jobs.push(work) + } + + return jobs + } catch (e) { + this.log.error(`${data.pokestop_id}: Can't seem to handle fort update: `, e, data) + } + } +} + +module.exports = FortUpdate diff --git a/src/controllers/monster.js b/src/controllers/monster.js index f9182620b..d29a068f5 100644 --- a/src/controllers/monster.js +++ b/src/controllers/monster.js @@ -638,6 +638,7 @@ class Monster extends Controller { form: data.form, name: monster.name, formName: monster.form.name, + fullName: data.fullName, iv: data.iv, cp: data.cp, latitude: data.latitude, diff --git a/src/lib/db/migrations/v18_fort.js b/src/lib/db/migrations/v18_fort.js new file mode 100644 index 000000000..dbeb7923f --- /dev/null +++ b/src/lib/db/migrations/v18_fort.js @@ -0,0 +1,26 @@ +const config = require('config') +const { log } = require('../../logger') + +exports.up = async function migrationUp(knex) { + await knex.schema.createTable('forts', (table) => { + if (config.database.client !== 'sqlite' && config.database.client !== 'sqlite3') table.increments('uid') + table.string('id').notNullable() + table.foreign('id').references('humans.id').onDelete('CASCADE') + table.integer('profile_no').notNullable().defaultTo(1) + table.string('ping').notNullable() + table.integer('distance').notNullable() + table.text('template').notNullable() + + table.string('fort_type').defaultTo('everything').notNullable() + table.boolean('include_empty').defaultTo(true).notNullable() + table.string('change_types').defaultTo('[]').notNullable() + + // table.unique(['id', 'profile_no', 'lure_id'], 'fort_tracking') + }) + + log.info('Fort watcher migration applied') +} + +exports.down = async function migrationDown(knex) { + log.info(knex) +} diff --git a/src/lib/discord/commando/commands/fort.js b/src/lib/discord/commando/commands/fort.js new file mode 100644 index 000000000..9901b866b --- /dev/null +++ b/src/lib/discord/commando/commands/fort.js @@ -0,0 +1,11 @@ +const PoracleDiscordMessage = require('../../poracleDiscordMessage') +const PoracleDiscordState = require('../../poracleDiscordState') + +const commandLogic = require('../../../poracleMessage/commands/fort') + +exports.run = async (client, msg, command) => { + const pdm = new PoracleDiscordMessage(client, msg) + const pds = new PoracleDiscordState(client) + + await commandLogic.run(pds, pdm, command[0]) +} diff --git a/src/lib/poracleMessage/commands/fort.js b/src/lib/poracleMessage/commands/fort.js new file mode 100644 index 000000000..8547f9af0 --- /dev/null +++ b/src/lib/poracleMessage/commands/fort.js @@ -0,0 +1,197 @@ +const helpCommand = require('./help') +const trackedCommand = require('./tracked') + +exports.run = async (client, msg, args, options) => { + const logReference = Math.random().toString().slice(2, 11) + + try { + const util = client.createUtil(msg, options) + + const { + canContinue, target, userHasLocation, userHasArea, language, currentProfileNo, + } = await util.buildTarget(args) + + if (!canContinue) return + const commandName = __filename.slice(__dirname.length + 1, -3) + client.log.info(`${logReference}: ${target.name}/${target.type}-${target.id}: ${commandName} ${args}`) + + if (args[0] === 'help') { + return helpCommand.run(client, msg, [commandName], options) + } + + const translator = client.translatorFactory.Translator(language) + + if (!await util.commandAllowed(commandName) && !args.find((arg) => arg === 'remove')) { + await msg.react('🚫') + return msg.reply(translator.translate('You do not have permission to execute this command')) + } + if (args.length === 0) { + const tipMsg = 'Valid commands are e.g. `{0}fort everything`, `{0}fort pokestop include_empty`, `{0}fort gym removal`' + + await msg.reply( + translator.translateFormat(tipMsg, util.prefix), + { style: 'markdown' }, + ) + await helpCommand.provideSingleLineHelp(client, msg, util, language, target, commandName) + return + } + + let reaction = '👌' + + const remove = !!args.find((arg) => arg === 'remove') + const commandEverything = !!args.find((arg) => arg === 'everything') + + let distance = 0 + let template = client.config.general.defaultTemplateName + let includeEmpty = false + const changes = [] + const pings = msg.getPings() + + let fortType + + args.forEach((element) => { + if (element.match(client.re.templateRe)) [,, template] = element.match(client.re.templateRe) + else if (element.match(client.re.dRe)) [,, distance] = element.match(client.re.dRe) + else if (element === 'pokestop') fortType = 'pokestop' + else if (element === 'gym') fortType = 'gym' + else if (element === 'everything') fortType = 'everything' + + else if (element === 'include empty') includeEmpty = true + else if (element === 'location') changes.push('location') + else if (element === 'name') changes.push('name') + else if (element === 'photo') changes.push('image_url') + else if (element === 'removal') changes.push('removal') + else if (element === 'new') changes.push('new') + }) + if (client.config.tracking.defaultDistance !== 0 && distance === 0 && !msg.isFromAdmin) distance = client.config.tracking.defaultDistance + if (client.config.tracking.maxDistance !== 0 && distance > client.config.tracking.maxDistance && !msg.isFromAdmin) distance = client.config.tracking.maxDistance + if (distance > 0 && !userHasLocation && !remove) { + await msg.react(translator.translate('🙅')) + return await msg.reply(`${translator.translate('Oops, a distance was set in command but no location is defined for your tracking - check the')} \`${util.prefix}${translator.translate('help')}\``) + } + if (distance === 0 && !userHasArea && !remove && !msg.isFromAdmin) { + await msg.react(translator.translate('🙅')) + return await msg.reply(`${translator.translate('Oops, no distance was set in command and no area is defined for your tracking - check the')} \`${util.prefix}${translator.translate('help')}\``) + } + if (distance === 0 && !userHasArea && !remove && msg.isFromAdmin) { + await msg.reply(`${translator.translate('Warning: Admin command detected without distance set - using default distance')} ${client.config.tracking.defaultDistance}`) + distance = client.config.tracking.defaultDistance + } + + // if (!teams.length) { + // return await msg.reply(translator.translate('404 No team types found')) + // } + + if (!remove) { + const insert = [{ + id: target.id, + profile_no: currentProfileNo, + ping: pings, + template: template.toString(), + distance: +distance, + fort_type: fortType, + include_empty: includeEmpty, + change_types: JSON.stringify(changes), + }] + + const tracked = await client.query.selectAllQuery('forts', { id: target.id, profile_no: currentProfileNo }) + const updates = [] + const alreadyPresent = [] + + for (let i = insert.length - 1; i >= 0; i--) { + const toInsert = insert[i] + + for (const existing of tracked.filter((x) => x.team === toInsert.team)) { + const differences = client.updatedDiff(existing, toInsert) + + switch (Object.keys(differences).length) { + case 1: // No differences (only UID) + // No need to insert + alreadyPresent.push(toInsert) + insert.splice(i, 1) + break + case 2: // One difference (something + uid) + if (Object.keys(differences).some((x) => ['distance', 'template', 'clean'].includes(x))) { + updates.push({ + ...toInsert, + uid: existing.uid, + }) + insert.splice(i, 1) + } + break + default: // more differences + break + } + } + } + + let message = '' + + if ((alreadyPresent.length + updates.length + insert.length) > 50) { + message = translator.translateFormat('I have made a lot of changes. See {0}{1} for details', util.prefix, translator.translate('tracked')) + } else { + for (const lure of alreadyPresent) { + message = message.concat(translator.translate('Unchanged: '), await trackedCommand.fortUpdateRowText(client.config, translator, client.GameData, lure, client.scannerQuery), '\n') + } + for (const lure of updates) { + message = message.concat(translator.translate('Updated: '), await trackedCommand.fortUpdateRowText(client.config, translator, client.GameData, lure, client.scannerQuery), '\n') + } + for (const lure of insert) { + message = message.concat(translator.translate('New: '), await trackedCommand.fortUpdateRowText(client.config, translator, client.GameData, lure, client.scannerQuery), '\n') + } + } + + await client.query.deleteWhereInQuery( + 'forts', + { + id: target.id, + profile_no: currentProfileNo, + }, + updates.map((x) => x.uid), + 'uid', + ) + + await client.query.insertQuery('forts', [...insert, ...updates]) + + client.log.info(`${logReference}: ${target.name} started tracking for fort updates ${changes.join(', ')}`) + await msg.reply(message, { style: 'markdown' }) + + reaction = insert.length ? '✅' : reaction + } else { + let result = 0 + // if (teams.length) { + // const lvlResult = await client.query.deleteWhereInQuery('forts', { + // id: target.id, + // profile_no: currentProfileNo, + // }, teams, 'team') + // client.log.info(`${logReference}: ${target.name} stopped tracking gym ${teams.join(', ')}`) + // result += lvlResult + // } + if (commandEverything) { + const everythingResult = await client.query.deleteQuery('forts', { + id: target.id, + profile_no: currentProfileNo, + }) + client.log.info(`${logReference}: ${target.name} stopped tracking all gyms`) + result += everythingResult + } + + msg.reply( + ''.concat( + result === 1 ? translator.translate('I removed 1 entry') + : translator.translateFormat('I removed {0} entries', result), + ', ', + translator.translateFormat('use `{0}{1}` to see what you are currently tracking', util.prefix, translator.translate('tracked')), + ), + { style: 'markdown' }, + ) + + reaction = result || client.config.database.client === 'sqlite' ? '✅' : reaction + } + + await msg.react(reaction) + } catch (err) { + client.log.error(`${logReference}: fort command unhappy:`, err) + msg.reply(`There was a problem making these changes, the administrator can find the details with reference ${logReference}`) + } +} diff --git a/src/lib/poracleMessage/commands/info.js b/src/lib/poracleMessage/commands/info.js index b75d2636e..c36fd3727 100644 --- a/src/lib/poracleMessage/commands/info.js +++ b/src/lib/poracleMessage/commands/info.js @@ -319,7 +319,7 @@ exports.run = async (client, msg, args, options) => { if (mon.evolutions) { message = message.concat(`\n**${translator.translate('Evolutions')}:**`) for (const evolution of mon.evolutions) { - message = message.concat(`\n${translator.translate(`${client.GameData.monsters[`${evolution.evoId}_${evolution.id}`].name}`)} (${evolution.candyCost} ${translator.translate('Candies')})`) + message = message.concat(`\n${translator.translate(`${client.GameData.monsters[`${evolution.evoId}_${evolution.id || 0}`]?.name || 'Unknown'}`)} (${evolution.candyCost} ${translator.translate('Candies')})`) if (evolution.itemRequirement) message = message.concat(`\n- ${translator.translate('Needed Item')}: ${translator.translate(evolution.itemRequirement)}`) if (evolution.mustBeBuddy) message = message.concat(`\n\u2705 ${translator.translate('Must Be Buddy')}`) if (evolution.onlyNighttime) message = message.concat(`\n\u2705 ${translator.translate('Only Nighttime')}`) diff --git a/src/lib/poracleMessage/commands/poracle-test.js b/src/lib/poracleMessage/commands/poracle-test.js index fd4ea99f9..bfc73a7fe 100644 --- a/src/lib/poracleMessage/commands/poracle-test.js +++ b/src/lib/poracleMessage/commands/poracle-test.js @@ -21,13 +21,14 @@ exports.run = async (client, msg, args, options) => { let template = client.config.general.defaultTemplateName?.toString() ?? '1' let language = client.config.general.locale - const validHooks = ['pokemon', 'raid', 'pokestop', 'gym', 'nest', 'quest'] + const validHooks = ['pokemon', 'raid', 'pokestop', 'gym', 'nest', 'quest', 'fort-update'] - const hookType = args[0] - if (!validHooks.includes(hookType)) { + const hookTypeDisplay = args[0] + if (!validHooks.includes(hookTypeDisplay)) { await msg.reply('Hooks supported are: '.concat(validHooks.join(', '))) return } + const hookType = hookTypeDisplay.replace(/-/g, '_') let testdata @@ -80,8 +81,8 @@ exports.run = async (client, msg, args, options) => { } if (dataItem.location !== 'keep') { - hook.latitude = human.latitude - hook.longitude = human.longitude + if (hook.latitude) hook.latitude = human.latitude + if (hook.longitude) hook.longitude = human.longitude } // Freshen test data @@ -104,6 +105,18 @@ exports.run = async (client, msg, args, options) => { case 'quest': { break } + case 'fort_update': { + if (hook.old?.location) { + hook.old.location.lat = human.latitude + hook.old.location.lon = human.longitude + } + if (hook.new?.location) { + // Approximately 100m away + hook.new.location.lat = human.latitude + 0.001 + hook.new.location.lon = human.longitude + 0.001 + } + break + } case 'gym': { break } diff --git a/src/lib/poracleMessage/commands/tracked.js b/src/lib/poracleMessage/commands/tracked.js index f5e92d8aa..f39cd88b0 100644 --- a/src/lib/poracleMessage/commands/tracked.js +++ b/src/lib/poracleMessage/commands/tracked.js @@ -185,6 +185,10 @@ function lureRowText(config, translator, GameData, lure) { return `${translator.translate('Lure type')}: **${translator.translate(typeText, true)}**${lure.distance ? ` | ${translator.translate('distance')}: ${lure.distance}m` : ''} ${standardText(config, translator, lure)}` } +function fortUpdateRowText(config, translator, GameData, fortUpdate) { + return `${translator.translate('Fort updates')}: **${translator.translate(fortUpdate.fort_type, true)}**${fortUpdate.distance ? ` | ${translator.translate('distance')}: ${fortUpdate.distance}m` : ''} ${fortUpdate.change_types}${fortUpdate.include_empty ? ' including empty changes' : ''} ${standardText(config, translator, fortUpdate)}` +} + function currentAreaText(translator, geofence, areas) { if (areas.length) { return `${translator.translate('You are currently set to receive alarms in')} ${geofence.filter((x) => areas.includes(x.name.toLowerCase())).map((x) => x.name).join(', ')}` @@ -200,6 +204,7 @@ exports.invasionRowText = invasionRowText exports.nestRowText = nestRowText exports.lureRowText = lureRowText exports.gymRowText = gymRowText +exports.fortUpdateRowText = fortUpdateRowText exports.currentAreaText = currentAreaText exports.run = async (client, msg, args, options) => { @@ -234,6 +239,7 @@ exports.run = async (client, msg, args, options) => { const lures = await client.query.selectAllQuery('lures', { id: target.id, profile_no: currentProfileNo }) const nests = await client.query.selectAllQuery('nests', { id: target.id, profile_no: currentProfileNo }) const gyms = await client.query.selectAllQuery('gym', { id: target.id, profile_no: currentProfileNo }) + const forts = await client.query.selectAllQuery('forts', { id: target.id, profile_no: currentProfileNo }) const profile = await client.query.selectOneQuery('profiles', { id: target.id, profile_no: currentProfileNo }) const blocked = human.blocked_alerts ? JSON.parse(human.blocked_alerts) : [] @@ -376,6 +382,20 @@ exports.run = async (client, msg, args, options) => { } } + if (!client.config.general.disableFortUpdate) { + if (blocked.includes('forts')) { + message = message.concat('\n\n', translator.translate('You do not have permission to track fort changes')) + } else { + if (nests.length) { + message = message.concat('\n\n', translator.translate('You\'re tracking the following fort changes:'), '\n') + } else message = message.concat('\n\n', translator.translate('You\'re not tracking any fort changes')) + + forts.forEach((fort) => { + message = message.concat('\n', fortUpdateRowText(client.config, translator, client.GameData, fort)) + }) + } + } + if (message.length < 4000) { return await msg.reply(message, { style: 'markdown' }) } diff --git a/src/lib/telegram/commands/fort.js b/src/lib/telegram/commands/fort.js new file mode 100644 index 000000000..46fd5fda5 --- /dev/null +++ b/src/lib/telegram/commands/fort.js @@ -0,0 +1,22 @@ +const PoracleTelegramMessage = require('../poracleTelegramMessage') +const PoracleTelegramState = require('../poracleTelegramState') + +const commandLogic = require('../../poracleMessage/commands/fort') + +module.exports = async (ctx) => { + const { controller, command } = ctx.state + + // channel message authors aren't identifiable, ignore all commands sent in channels + if (Object.keys(ctx.update).includes('channel_post')) return + + try { + const ptm = new PoracleTelegramMessage(ctx) + const pts = new PoracleTelegramState(ctx) + + for (const c of command.splitArgsArray) { + await commandLogic.run(pts, ptm, c) + } + } catch (err) { + controller.logs.telegram.error('Fort command unhappy:', err) + } +} \ No newline at end of file