import throat from 'throat'; import * as _ from 'lodash'; import log from 'electron-log'; //tslint:disable-line:match-default-export-name import core from '../core'; import { Conversation } from '../interfaces'; import Timer = NodeJS.Timer; import ChannelConversation = Conversation.ChannelConversation; const adManagerThroat = throat(1); export interface RecoverableAd { channel: string; index: number; nextPostDue: Date | undefined, firstPost: Date | undefined, expireDue: Date | undefined; } export class AdManager { static readonly POSTING_PERIOD = 3 * 60 * 60 * 1000; static readonly START_VARIANCE = 3 * 60 * 1000; static readonly POST_VARIANCE = 8 * 60 * 1000; static readonly POST_DELAY = 1.5 * 60 * 1000; static readonly POST_MANUAL_THRESHOLD = 5 * 1000; // don't post anything within 5 seconds of other posts private conversation: Conversation; private adIndex = 0; private active = false; private nextPostDue?: Date; private expireDue?: Date; private firstPost?: Date; private interval?: Timer; private adMap: number[] = []; constructor(conversation: Conversation) { this.conversation = conversation; } isActive(): boolean { return this.active; } skipAd(): void { this.adIndex += 1; } // tslint:disable-next-line private async delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // This makes sure there is a 5s delay between channel posts private async sendAdToChannel(msg: string, conv: Conversation.ChannelConversation): Promise { const initTime = Date.now(); await adManagerThroat( async() => { const throatTime = Date.now(); const delta = Date.now() - core.cache.getLastPost().getTime(); if ((delta > 0) && (delta < AdManager.POST_MANUAL_THRESHOLD)) { await this.delay(delta); } const delayTime = Date.now(); log.debug( 'adManager.sendAdToChannel', { character: core.characters.ownCharacter?.name, channel: conv.channel.name, throatDelta: throatTime - initTime, delayDelta: delayTime - throatTime, totalWait: delayTime - initTime, msg } ); await conv.sendAd(msg); } ); } private async sendNextPost(): Promise { const msg = this.getNextAd(); if ((!msg) || ((this.expireDue) && (this.expireDue.getTime() < Date.now()))) { this.stop(); return; } const chanConv = (this.conversation); await this.sendAdToChannel(msg, chanConv); // post next ad every 12 - 22 minutes const nextInMs = Math.max(0, (chanConv.nextAd - Date.now())) + AdManager.POST_DELAY + Math.random() * AdManager.POST_VARIANCE; this.adIndex = this.adIndex + 1; this.nextPostDue = new Date(Math.max( Date.now() + nextInMs, (chanConv.settings.adSettings.lastAdTimestamp || 0) + (core.connection.vars.lfrp_flood * 1000) )); // tslint:disable-next-line: no-unnecessary-type-assertion this.interval = setTimeout( async() => { await this.sendNextPost(); }, nextInMs ) as Timer; } generateAdMap(): number[] { const ads = this.getAds(); const idx = _.range(ads.length); return this.shouldUseRandomOrder() ? _.shuffle(idx) : idx; } shouldUseRandomOrder(): boolean { return !!this.conversation.settings.adSettings.randomOrder; } getAds(): string[] { return this.conversation.settings.adSettings.ads; } getNextAd(): string | undefined { const ads = this.getAds(); if (ads.length === 0) return; if (ads.length !== this.adMap.length) { log.debug('adManager.regenerate.on-the-fly', ads.length, this.adMap.length); this.adMap = this.generateAdMap(); } return ads[this.adMap[this.adIndex % this.adMap.length] % ads.length]; } getNextPostDue(): Date | undefined { return this.nextPostDue; } getExpireDue(): Date | undefined { return this.expireDue; } getFirstPost(): Date | undefined { return this.firstPost; } start(timeoutMs = AdManager.POSTING_PERIOD): void { const chanConv = (this.conversation); const initialWait = Math.max( Math.random() * AdManager.START_VARIANCE, (chanConv.nextAd - Date.now()) * 1.1, ((this.conversation.settings.adSettings.lastAdTimestamp || 0) + (core.connection.vars.lfrp_flood * 1000)) - Date.now() ); this.adIndex = 0; this.active = true; this.nextPostDue = new Date(Math.max( Date.now() + initialWait, (this.conversation.settings.adSettings.lastAdTimestamp || 0) + (core.connection.vars.lfrp_flood * 1000) )); this.expireDue = new Date(Date.now() + timeoutMs); this.adMap = this.generateAdMap(); // tslint:disable-next-line: no-unnecessary-type-assertion this.interval = setTimeout( async() => { this.firstPost = new Date(); await this.sendNextPost(); }, initialWait ) as Timer; } protected forceTimeout(waitTime: number): void { if (this.interval) { clearTimeout(this.interval); } // tslint:disable-next-line: no-unnecessary-type-assertion this.interval = setTimeout( async() => { await this.sendNextPost(); }, waitTime ) as Timer; } stop(): void { if (this.interval) clearTimeout(this.interval); delete this.interval; delete this.nextPostDue; delete this.expireDue; delete this.firstPost; this.active = false; this.adIndex = 0; // const message = new EventMessage(`Advertisements on channel [channel]${this.conversation.name}[/channel] have expired.`); // addEventMessage(message); } renew(): void { if (!this.active) return; this.expireDue = new Date(Date.now() + 3 * 60 * 60 * 1000); } protected static recoverableCharacter = ''; protected static recoverableAds: RecoverableAd[] = []; static onConnectionClosed(): void { AdManager.recoverableCharacter = _.get(core, 'characters.ownCharacter.name', ''); AdManager.recoverableAds = _.map( _.filter(core.conversations.channelConversations, (c) => ((c.adManager) && (c.adManager.isActive()))), (chanConv): RecoverableAd => { const adManager = chanConv.adManager; return { channel : chanConv.name, index : adManager.adIndex, nextPostDue : adManager.nextPostDue, firstPost : adManager.firstPost, expireDue : adManager.expireDue }; } ); _.each( _.filter(core.conversations.channelConversations, (c) => ((c.adManager) && (c.adManager.isActive()))), (c) => c.adManager.stop() ); } static onNewChannelAvailable(channel: ChannelConversation): void { if (AdManager.recoverableCharacter !== core.characters.ownCharacter.name) { AdManager.recoverableAds = []; AdManager.recoverableCharacter = ''; return; } const ra = _.find(AdManager.recoverableAds, (r) => (r.channel === channel.name)); if (!ra) { return; } const adManager = channel.adManager; adManager.stop(); adManager.start(); adManager.adIndex = ra.index; adManager.firstPost = ra.firstPost; adManager.nextPostDue = adManager.nextPostDue || ra.nextPostDue || new Date(); adManager.expireDue = ra.expireDue; adManager.forceTimeout( Math.max(0, adManager.nextPostDue.getTime() - Date.now()) ); AdManager.recoverableAds = _.filter(AdManager.recoverableAds, (r) => (r.channel !== ra.channel)); } }