fchat-rising/learn/cache-manager.ts

541 lines
17 KiB
TypeScript

import * as _ from 'lodash';
import core from '../chat/core';
import { ChannelAdEvent, ChannelMessageEvent, CharacterDataEvent, EventBus, SelectConversationEvent } from '../chat/preview/event-bus';
import { Channel, Conversation } from '../chat/interfaces';
import { methods } from '../site/character_page/data_store';
import { Character as ComplexCharacter } from '../site/character_page/interfaces';
import { AdCache } from './ad-cache';
import { ChannelConversationCache } from './channel-conversation-cache';
import { CharacterProfiler } from './character-profiler';
import { CharacterCacheRecord, ProfileCache } from './profile-cache';
import Timer = NodeJS.Timer;
import ChannelConversation = Conversation.ChannelConversation;
import Message = Conversation.Message;
import { Character } from '../fchat/interfaces';
import Bluebird from 'bluebird';
import ChatMessage = Conversation.ChatMessage;
import { GeneralSettings } from '../electron/common';
import { Gender } from './matcher-types';
import { WorkerStore } from './store/worker';
import { PermanentIndexedStore } from './store/types';
import * as path from 'path';
// import * as electron from 'electron';
import log from 'electron-log';
import { testSmartFilterForPrivateMessage } from '../chat/conversations'; //tslint:disable-line:match-default-export-name
export interface ProfileCacheQueueEntry {
name: string;
key: string;
added: Date;
gender?: Gender;
score: number;
channelId?: string;
retryCount: number;
}
export class CacheManager {
// @ts-ignore
private _isVue = true;
private readonly startTime = new Date();
static readonly PROFILE_QUERY_DELAY = 400; //1 * 1000;
adCache: AdCache = new AdCache();
profileCache: ProfileCache = new ProfileCache();
channelConversationCache: ChannelConversationCache = new ChannelConversationCache();
protected queue: ProfileCacheQueueEntry[] = [];
protected profileTimer: Timer | null = null;
protected characterProfiler: CharacterProfiler | undefined;
protected profileStore?: PermanentIndexedStore;
protected lastPost: Date = new Date();
protected lastFetch = Date.now();
protected fetchLog: Record<string, number> = {};
protected ongoingLog: Record<string, true> = {};
protected isActiveTab = true;
setTabActive(isActive: boolean): void {
this.isActiveTab = isActive;
if (this.isActiveTab) {
void this.onSelectConversation({ conversation: core.conversations.selectedConversation });
} else {
void this.onSelectConversation({ conversation: null! });
}
}
markLastPostTime(): void {
this.lastPost = new Date();
}
getLastPost(): Date {
return this.lastPost;
}
async queueForFetching(name: string, skipCacheCheck: boolean = false, channelId?: string): Promise<void> {
if (!core.state.settings.risingAdScore) {
return;
}
log.debug('profile.cache.queue', { name, skipCacheCheck, channelId, from: core.characters.ownCharacter.name });
if (!skipCacheCheck) {
const c = await this.profileCache.get(name);
if (c) {
this.updateAdScoringForProfile(c.character, c.match.matchScore, c.match.isFiltered);
return;
}
}
const key = ProfileCache.nameKey(name);
if (!!_.find(this.queue, (q: ProfileCacheQueueEntry) => (q.key === key)))
return;
const entry: ProfileCacheQueueEntry = {
name,
key,
channelId,
added: new Date(),
score: 0,
retryCount: 0
};
this.queue.push(entry);
// console.log('Added to queue', entry.name, entry.added.toISOString());
// console.log('AddProfileForFetching', name, this.queue.length);
}
async fetchProfile(name: string): Promise<ComplexCharacter | null> {
try {
await methods.fieldsGet();
const c = await methods.characterData(name, -1, true);
const r = await this.profileCache.register(c);
this.updateAdScoringForProfile(c, r.match.matchScore, r.match.isFiltered);
return c;
} catch (err) {
console.error('Failed to fetch profile for cache', name, err);
return null;
}
}
updateAdScoringForProfile(c: ComplexCharacter, score: number, isFiltered: boolean): void {
EventBus.$emit(
'character-score',
{
character: c,
score,
isFiltered
}
);
this.populateAllConversationsWithScore(c.character.name, score, isFiltered);
void this.respondToPendingRejections(c);
}
// Manage rejections in case we didn't have a score at the time we received the message
async respondToPendingRejections(c: ComplexCharacter): Promise<void> {
const char = core.characters.get(c.character.name);
if (char && char.status !== 'offline') {
const conv = core.conversations.getPrivate(char, true);
if (conv && conv.messages.length > 0 && Date.now() - _.last(conv.messages)!.time.getTime() < 5 * 60 * 1000) {
const sessionMessages = _.filter(conv.messages, (m) => m.time.getTime() >= this.startTime.getTime());
const allMessagesFromThem = _.every(
sessionMessages,
(m) => ('sender' in m) && m.sender.name === conv.character.name
);
if (sessionMessages.length > 0 && allMessagesFromThem) {
await testSmartFilterForPrivateMessage(char);
}
}
}
}
async addProfile(character: string | ComplexCharacter): Promise<void> {
if (typeof character === 'string') {
// console.log('Learn discover', character);
await this.queueForFetching(character);
return;
}
await this.profileCache.register(character);
}
/*
* Preference in order (plan):
* + has messaged me
* + bookmarked / friend
*
* + genders I like
* + looking
* + online
*
* - busy
* - DND
* - away
*/
consumeNextInQueue(): ProfileCacheQueueEntry | null {
if (this.queue.length === 0) {
return null;
}
// re-score
_.each(this.queue, (e: ProfileCacheQueueEntry) => e.score = this.calculateScore(e));
this.queue = _.sortBy(this.queue, 'score');
// console.log('QUEUE', _.map(this.queue, (q) => `${q.name}: ${q.score}`));
const entry = this.queue.pop() as ProfileCacheQueueEntry;
if (entry) {
// just in case - remove duplicates
this.queue = _.filter(this.queue, (q) => q.name !== entry.name);
}
// console.log('PopFromQueue', entry.name, this.queue.length);
return entry;
}
calculateScore(e: ProfileCacheQueueEntry): number {
return this.characterProfiler ? this.characterProfiler.calculateInterestScoreForQueueEntry(e) : 0;
}
async start(settings: GeneralSettings, skipFlush: boolean): Promise<void> {
await this.stop();
this.profileStore = await WorkerStore.open(
path.join(/*electron.remote.app.getAppPath(),*/ 'storeWorkerEndpoint.js')
); // await IndexedStore.open();
this.profileCache.setStore(this.profileStore);
if (!skipFlush) {
await this.profileStore.flushProfiles(settings.risingCacheExpiryDays);
}
EventBus.$on(
'character-data',
async(data: CharacterDataEvent) => {
// this promise is intentionally NOT chained
// tslint:disable-next-line: no-floating-promises
this.onCharacterData(data);
}
);
EventBus.$on(
'channel-message',
async(data: ChannelMessageEvent) => {
// this promise is intentionally NOT chained
// tslint:disable-next-line: no-floating-promises
this.onChannelMessage(data);
}
);
EventBus.$on(
'channel-ad',
async(data: ChannelAdEvent) => {
// this promise is intentionally NOT chained
// tslint:disable-next-line: no-floating-promises
this.onChannelAd(data);
}
);
EventBus.$on(
'select-conversation',
async(data: SelectConversationEvent) => {
// this promise is intentionally NOT chained
// tslint:disable-next-line: no-floating-promises
this.onSelectConversation(data);
}
);
EventBus.$on(
'conversation-load-more',
async(data: SelectConversationEvent) => {
// this promise is intentionally NOT chained
// tslint:disable-next-line: no-floating-promises
this.onLoadMoreConversation(data);
}
);
// EventBus.$on(
// 'private-message',
// (data: any) => {}
// );
const scheduleNextFetch = () => {
this.profileTimer = setTimeout(
async() => {
// const d = Date.now();
const next = this.consumeNextInQueue();
if (next) {
// console.log('Next in queue', next.name, (Date.now() - d) / 1000.0);
try {
let skipFetch = false;
if (
(next.name in this.ongoingLog) ||
((next.name in this.fetchLog) && (Date.now() - this.fetchLog[next.name] < 120000))
) {
skipFetch = true;
}
// tslint:disable-next-line: binary-expression-operand-order
if ((false) && (next)) {
console.log(`Fetch '${next.name}' for channel '${next.channelId}', gap: ${(Date.now() - this.lastFetch)}ms`);
this.lastFetch = Date.now();
}
if (!skipFetch) {
this.ongoingLog[next.name] = true;
await this.fetchProfile(next.name);
this.fetchLog[next.name] = Date.now();
}
// just in case - remove duplicates
this.queue = _.filter(this.queue, (q) => q.name !== next.name);
delete this.ongoingLog[next.name];
} catch (err) {
console.error('Profile queue error', err);
delete this.ongoingLog[next.name];
next.retryCount += 1;
if (next.retryCount < 10) {
this.queue.push(next); // return to queue
}
}
// console.log('Completed', next.name, (Date.now() - d) / 1000.0);
}
scheduleNextFetch();
},
CacheManager.PROFILE_QUERY_DELAY
);
};
scheduleNextFetch();
}
async onCharacterData(data: CharacterDataEvent): Promise<void> {
await this.addProfile(data.character);
}
async onChannelMessage(data: ChannelMessageEvent): Promise<void> {
const message = data.message;
const channel = data.channel;
this.channelConversationCache.register(
{
name: message.sender.name,
channelName : channel.name,
datePosted: message.time,
message: message.text
}
);
// await this.addProfile(message.sender.name);
}
async onChannelAd(data: ChannelAdEvent): Promise<void> {
const message = data.message;
const channel = data.channel;
this.adCache.register(
{
name: message.sender.name,
channelName : channel.name,
datePosted: message.time,
message: message.text
}
);
if (
(!data.profile) &&
(core.conversations.selectedConversation === data.channel) &&
(this.isActiveTab)
) {
await this.queueForFetching(message.sender.name, true, data.channel.channel.id);
}
// this.addProfile(message.sender.name);
}
async onLoadMoreConversation(data: SelectConversationEvent): Promise<void> {
await this.onSelectConversation(data);
}
async onSelectConversation(data: SelectConversationEvent): Promise<void> {
const conversation = data.conversation;
const channel = _.get(conversation, 'channel') as (Channel.Channel | undefined);
const channelId = _.get(channel, 'id', '<missing>');
// Remove unfinished fetches related to other channels
this.queue = _.reject(
this.queue,
(q) => (!!q.channelId) && (q.channelId !== channelId)
);
if (channel) {
const checkedNames: Record<string, boolean> = {};
// Add fetchers for unknown profiles in ads
await Bluebird.each(
_.filter(
conversation!.messages,
(m) => {
if (m.type !== Message.Type.Ad) {
return false;
}
const chatMessage = m as unknown as ChatMessage;
if (chatMessage.sender.name in checkedNames) {
return false;
}
checkedNames[chatMessage.sender.name] = true;
return true;
}
),
async(m: Message) => {
const chatMessage: ChatMessage = m as unknown as ChatMessage;
if (chatMessage.score) {
return;
}
const p = await this.resolvePScore(false, chatMessage.sender, conversation as ChannelConversation, chatMessage, true);
if (!p) {
await this.queueForFetching(chatMessage.sender.name, true, channel.id);
}
}
);
}
}
async resolvePScore(
skipStore: boolean,
char: Character.Character,
conv: ChannelConversation,
msg?: Message,
populateAll: boolean = true
): Promise<CharacterCacheRecord | undefined> {
if (!core.characters.ownProfile) {
return undefined;
}
// this is done here so that the message will be rendered correctly when cache is hit
let p: CharacterCacheRecord | undefined;
p = await core.cache.profileCache.get(
char.name,
skipStore,
conv.channel.name
) || undefined;
if ((p) && (msg)) {
// if (p.matchScore === 0) {
// console.log(`Fetched score 0 for character ${char.name}`);
//
// p.matchScore = ProfileCache.score(p.character);
//
// await core.cache.profileCache.register(p.character, false);
//
// console.log(`Re-scored character ${char.name} to ${p.matchScore}`);
// }
msg.score = p.match.matchScore;
msg.filterMatch = p.match.isFiltered;
if (populateAll) {
this.populateAllConversationsWithScore(char.name, p.match.matchScore, p.match.isFiltered);
}
}
return p;
}
// tslint:disable-next-line: prefer-function-over-method
public populateAllConversationsWithScore(characterName: string, score: number, isFiltered: boolean): void {
_.each(
core.conversations.channelConversations,
(ch: ChannelConversation) => {
_.each(
ch.messages, (m: Conversation.Message) => {
if ((m.type === Message.Type.Ad) && (m.sender) && (m.sender.name === characterName)) {
// console.log('Update score', score, ch.name, m.sender.name, m.text, m.id);
m.score = score;
m.filterMatch = isFiltered;
}
}
);
}
);
}
async stop(): Promise<void> {
if (this.profileTimer) {
clearTimeout(this.profileTimer);
this.profileTimer = null;
}
if (this.profileStore) {
await this.profileStore.stop();
}
// should do some $off here?
}
setProfile(c: ComplexCharacter): void {
this.characterProfiler = new CharacterProfiler(c, this.adCache);
}
}