From 65ab5ffa32516f7ce55e0a1ac4145b51b35c0306 Mon Sep 17 00:00:00 2001 From: "Mr. Stallion" <mrstallion@nobody.nowhere.fauxdomain.ext> Date: Fri, 31 Dec 2021 18:06:08 -0600 Subject: [PATCH] Smart filters --- CHANGELOG.md | 3 + chat/CharacterSearch.vue | 10 +- chat/ConversationView.vue | 28 ++++- chat/SettingsView.vue | 155 ++++++++++++++++++++++- chat/UserList.vue | 25 +++- chat/common.ts | 37 ++++++ chat/conversations.ts | 42 ++++++- chat/core.ts | 10 +- chat/interfaces.ts | 4 + chat/message_view.ts | 17 ++- learn/cache-manager.ts | 14 ++- learn/filter/smart-filter.ts | 235 +++++++++++++++++++++++++++++++++++ learn/filter/types.ts | 47 +++++++ learn/matcher-types.ts | 130 ++++++++++++++++++- learn/matcher.ts | 15 ++- learn/profile-cache.ts | 17 ++- 16 files changed, 760 insertions(+), 29 deletions(-) create mode 100644 learn/filter/smart-filter.ts create mode 100644 learn/filter/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f56b861..49cf4ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## Canary +* Added a way to hide/filter out characters, messages, and ads (Settings > Identity Politics) + ## 1.16.2 * Fixed broken auto-ads diff --git a/chat/CharacterSearch.vue b/chat/CharacterSearch.vue index d3ca16c..43bf674 100644 --- a/chat/CharacterSearch.vue +++ b/chat/CharacterSearch.vue @@ -270,7 +270,7 @@ core.connection.onMessage('FKS', async (data) => { const results = data.characters.map((x) => ({ character: core.characters.get(x), profile: null })) .filter((x) => core.state.hiddenUsers.indexOf(x.character.name) === -1 && !x.character.isIgnored) - .filter((x) => this.isSpeciesMatch(x) && this.isBodyTypeMatch(x)) + .filter((x) => this.isSpeciesMatch(x) && this.isBodyTypeMatch(x) && this.isSmartFilterMatch(x)) .sort(sort); // pre-warm cache @@ -399,6 +399,14 @@ return this.data.bodytypes.indexOf(bodytype!.value) > -1 } + isSmartFilterMatch(result: SearchResult) { + if (!core.state.settings.risingFilter.hideSearchResults) { + return true; + } + + return result.profile ? !result.profile?.match.isFiltered : true; + } + getSpeciesOptions(): SearchSpecies[] { const species = _.map( speciesMapping, diff --git a/chat/ConversationView.vue b/chat/ConversationView.vue index 03872e6..9bbbf4e 100644 --- a/chat/ConversationView.vue +++ b/chat/ConversationView.vue @@ -449,16 +449,26 @@ /* tslint:disable */ getMessageWrapperClasses(): any { + const filter = core.state.settings.risingFilter; + const classes:any = {}; + + if (this.isPrivate(this.conversation)) { + classes['filter-channel-messages'] = filter.hidePrivateMessages; + return classes; + } + if (!this.isChannel(this.conversation)) { return {}; } const conv = <Conversation.ChannelConversation>this.conversation; - const classes:any = {}; classes['messages-' + conv.mode] = true; classes['hide-non-matching'] = !this.showNonMatchingAds; + classes['filter-ads'] = filter.hideAds; + classes['filter-channel-messages'] = conv.channel.owner !== '' ? filter.hidePrivateChannelMessages : filter.hidePublicChannelMessages; + return classes; } @@ -839,12 +849,26 @@ } - .messages.hide-non-matching .message.message-score { + .messages.hide-non-matching .message.message-score, + { &.mismatch { display: none; } } + .messages.filter-ads { + .message.filter-match.message-ad { + display: none; + } + } + + .messages.filter-channel-messages { + .message.filter-match.message-message, + .message.filter-match.message-action { + display: none; + } + } + .message { .message-pre { font-size: 75%; diff --git a/chat/SettingsView.vue b/chat/SettingsView.vue index 5a93a49..e2cb43b 100644 --- a/chat/SettingsView.vue +++ b/chat/SettingsView.vue @@ -1,7 +1,7 @@ <template> <modal :action="l('settings.action')" @submit="submit" @open="load()" id="settings" dialogClass="w-100"> <tabs style="flex-shrink:0;margin-bottom:10px" v-model="selectedTab" - :tabs="[l('settings.tabs.general'), l('settings.tabs.notifications'), 'F-Chat Rising 🦄', l('settings.tabs.hideAds'), l('settings.tabs.import')]"></tabs> + :tabs="[l('settings.tabs.general'), l('settings.tabs.notifications'), 'F-Chat Rising 🦄', 'Identity Politics 🦄', l('settings.tabs.hideAds'), l('settings.tabs.import')]"></tabs> <div v-show="selectedTab === '0'"> <div class="form-group"> <label class="control-label" for="disallowedTags">{{l('settings.disallowedTags')}}</label> @@ -200,7 +200,83 @@ </div> </div> + <div v-show="selectedTab === '3'"> + <h5>Visibility</h5> + + <div class="form-group filters"> + <label class="control-label" for="risingFilter.hideAds"> + <input type="checkbox" id="risingFilter.hideAds" v-model="risingFilter.hideAds"/> + Hide <b>ads</b> from matching characters + </label> + + <label class="control-label" for="risingFilter.hideSearchResults"> + <input type="checkbox" id="risingFilter.hideSearchResults" v-model="risingFilter.hideSearchResults"/> + Hide matching characters from <b>search results</b> + </label> + + <label class="control-label" for="risingFilter.hideChannelMembers"> + <input type="checkbox" id="risingFilter.hideChannelMembers" v-model="risingFilter.hideChannelMembers"/> + Hide matching characters from <b>channel members lists</b> + </label> + + <label class="control-label" for="risingFilter.hidePublicChannelMessages"> + <input type="checkbox" id="risingFilter.hidePublicChannelMessages" v-model="risingFilter.hidePublicChannelMessages"/> + Hide <b>public channel messages</b> from matching characters + </label> + + <label class="control-label" for="risingFilter.hidePrivateChannelMessages"> + <input type="checkbox" id="risingFilter.hidePrivateChannelMessages" v-model="risingFilter.hidePrivateChannelMessages"/> + Hide <b>private channel messages</b> from matching characters + </label> + + <label class="control-label" for="risingFilter.hidePrivateMessages"> + <input type="checkbox" id="risingFilter.hidePrivateMessages" v-model="risingFilter.hidePrivateMessages"/> + Hide <b>private messages</b> (PMs) from matching characters + </label> + + <label class="control-label" for="risingFilter.penalizeMatches"> + <input type="checkbox" id="risingFilter.penalizeMatches" v-model="risingFilter.penalizeMatches"/> + Penalize <b>match scores</b> for matching characters + </label> + </div> + + <div class="form-group filters"> + <label class="control-label" for="risingFilter.autoReply"> + <input type="checkbox" id="risingFilter.autoReply" v-model="risingFilter.autoReply"/> + Send an automatic 'no thank you' response to matching characters if they message you + </label> + </div> + + <h5>Character Age Match</h5> + <div class="form-group">Leave empty for no limit.</div> + + <div class="form-group"> + <label class="control-label" for="risingFilter.minAge">Characters younger than</label> + <input id="risingFilter.minAge" type="number" class="form-control" v-model="risingFilter.minAge"/> + + <label class="control-label" for="risingFilter.maxAge">Characters older than</label> + <input id="risingFilter.maxAge" type="number" class="form-control" v-model="risingFilter.maxAge"/> + </div> + + <h5>Type Match</h5> + <div class="form-group filters" > + <label class="control-label" :for="'risingFilter.smartFilters.' + key" v-for="(value, key) in smartFilterTypes"> + <input type="checkbox" :id="'risingFilter.smartFilters.' + key" v-bind:checked="getSmartFilter(key)" @change="(v) => setSmartFilter(key, v)"/> + {{value.name}} + </label> + </div> + + <h5>Exception List</h5> + <div class="form-group">Filters are not applied to these character names. Separate names with a linefeed.</div> + + <div class="form-group"> + <textarea class="form-control" :value="getExceptionList()" @change="(v) => setExceptionList(v)"></textarea> + </div> + </div> + + + <div v-show="selectedTab === '4'"> <template v-if="hidden.length"> <div v-for="(user, i) in hidden"> <span class="fa fa-times" style="cursor:pointer" @click.stop="hidden.splice(i, 1)"></span> @@ -209,7 +285,7 @@ </template> <template v-else>{{l('settings.hideAds.empty')}}</template> </div> - <div v-show="selectedTab === '4'" style="display:flex;padding-top:10px"> + <div v-show="selectedTab === '5'" style="display:flex;padding-top:10px"> <select id="import" class="form-control" v-model="importCharacter" style="flex:1;margin-right:10px"> <option value="">{{l('settings.import.selectCharacter')}}</option> <option v-for="character in availableImports" :value="character">{{character}}</option> @@ -227,6 +303,10 @@ import core from './core'; import {Settings as SettingsInterface} from './interfaces'; import l from './localize'; + import { SmartFilterSettings, SmartFilterSelection } from '../learn/filter/types'; + import { smartFilterTypes as smartFilterTypesOrigin } from '../learn/filter/types'; + import _ from 'lodash'; + import { matchesSmartFilters } from '../learn/filter/smart-filter'; @Component({ components: {modal: Modal, tabs: Tabs} @@ -269,6 +349,9 @@ risingShowUnreadOfflineCount!: boolean; risingColorblindMode!: boolean; + risingFilter!: SmartFilterSettings = {} as any; + + smartFilterTypes = smartFilterTypesOrigin; async load(): Promise<void> { const settings = core.state.settings; @@ -305,6 +388,7 @@ this.risingShowUnreadOfflineCount = settings.risingShowUnreadOfflineCount; this.risingColorblindMode = settings.risingColorblindMode; + this.risingFilter = settings.risingFilter; } async doImport(): Promise<void> { @@ -325,8 +409,14 @@ } async submit(): Promise<void> { + const oldRisingFilter = JSON.parse(JSON.stringify(core.state.settings.risingFilter)); + const idleTimer = parseInt(this.idleTimer, 10); const fontSize = parseFloat(this.fontSize); + + const minAge = this.getAsNumber(this.risingFilter.minAge); + const maxAge = this.getAsNumber(this.risingFilter.maxAge); + core.state.settings = { playSound: this.playSound, clickOpensMessage: this.clickOpensMessage, @@ -360,9 +450,63 @@ risingShowUnreadOfflineCount: this.risingShowUnreadOfflineCount, risingColorblindMode: this.risingColorblindMode, + risingFilter: { + ...this.risingFilter, + minAge: (minAge !== null && maxAge !== null) ? Math.min(minAge, maxAge) : minAge, + maxAge: (minAge !== null && maxAge !== null) ? Math.max(minAge, maxAge) : maxAge + } }; + + console.log('SETTINGS', minAge, maxAge, core.state.settings); + + const newRisingFilter = JSON.parse(JSON.stringify(core.state.settings.risingFilter)); + + if (!_.isEqual(oldRisingFilter, newRisingFilter)) { + this.rebuildFilters(); + } + if(this.notifications) await core.notifications.requestPermission(); } + + rebuildFilters() { + core.cache.profileCache.onEachInMemory( + (c) => { + const oldFiltered = c.match.isFiltered; + + c.match.isFiltered = matchesSmartFilters(c.character.character, core.state.settings.risingFilter); + + if (oldFiltered !== c.match.isFiltered) { + core.cache.populateAllConversationsWithScore(c.character.character.name, c.match.matchScore, c.match.isFiltered); + } + } + ); + } + + getAsNumber(input: any): number | null { + if (_.isNil(input) || input === '') { + return null; + } + + const n = parseInt(input, 10); + + return !Number.isNaN(n) && Number.isFinite(n) ? n : null; + } + + getExceptionList(): string { + return _.join(this.risingFilter.exceptionNames, '\n'); + } + + setExceptionList(v: any): void { + this.risingFilter.exceptionNames = _.map(_.split(v.target.value), (v) => _.trim(v)); + } + + getSmartFilter(key: keyof SmartFilterSelection): boolean { + return !!this.risingFilter.smartFilters?.[key]; + } + + setSmartFilter(key: keyof SmartFilterSelection , value: any): void { + this.risingFilter.smartFilters[key] = value.target.checked; + } } </script> @@ -371,4 +515,11 @@ margin-left: 0; margin-right: 0; } + + #settings .form-group.filters label { + display: list-item; + margin: 0; + margin-left: 5px; + list-style: none; + } </style> diff --git a/chat/UserList.vue b/chat/UserList.vue index 9ecd8cd..0c6a228 100644 --- a/chat/UserList.vue +++ b/chat/UserList.vue @@ -61,9 +61,26 @@ } get filteredMembers(): ReadonlyArray<Channel.Member> { - if(this.filter.length === 0) return this.channel.sortedMembers; - const filter = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i'); - return this.channel.sortedMembers.filter((member) => filter.test(member.character.name)); + const members = this.prefilterMembers(); + + if (!core.state.settings.risingFilter.hideChannelMembers) { + return members; + } + + return members.filter((m) => { + const p = core.cache.profileCache.getSync(m.character.name); + + return !p || !p.match.isFiltered; + }); + } + + prefilterMembers(): ReadonlyArray<Channel.Member> { + if(this.filter.length === 0) + return this.channel.sortedMembers; + + const filter = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i'); + + return this.channel.sortedMembers.filter((member) => filter.test(member.character.name)); } } </script> @@ -106,4 +123,4 @@ display: flex; } } -</style> \ No newline at end of file +</style> diff --git a/chat/common.ts b/chat/common.ts index d9b5c2b..3834e5a 100644 --- a/chat/common.ts +++ b/chat/common.ts @@ -54,6 +54,41 @@ export class Settings implements ISettings { risingShowUnreadOfflineCount = true; risingColorblindMode = false; + + risingFilter = { + hideAds: false, + hideSearchResults: false, + hideChannelMembers: false, + hidePublicChannelMessages: false, + hidePrivateChannelMessages: false, + hidePrivateMessages: false, + penalizeMatches: false, + autoReply: true, + minAge: null, + maxAge: null, + smartFilters: { + ageplay: false, + anthro: false, + feral: false, + human: false, + hyper: false, + incest: false, + microMacro: false, + obesity: false, + pokemon: false, + pregnancy: false, + rape: false, + scat: false, + std: false, + taur: false, + gore: false, + vore: false, + unclean: false, + watersports: false, + zoophilia: false + }, + exceptionNames: [] + }; } @@ -114,6 +149,7 @@ export class Message implements Conversation.ChatMessage { isHighlight = false; score = 0; + filterMatch = false; constructor(readonly type: Conversation.Message.Type, readonly sender: Character, readonly text: string, readonly time: Date = new Date()) { @@ -126,6 +162,7 @@ export class EventMessage implements Conversation.EventMessage { readonly type = Conversation.Message.Type.Event; readonly score = 0; + filterMatch = false; constructor(readonly text: string, readonly time: Date = new Date()) { } diff --git a/chat/conversations.ts b/chat/conversations.ts index d6a192c..5cd06f6 100644 --- a/chat/conversations.ts +++ b/chat/conversations.ts @@ -4,14 +4,16 @@ import {decodeHTML} from '../fchat/common'; import { AdManager } from './ads/ad-manager'; import { characterImage, ConversationSettings, EventMessage, Message, messageToString } from './common'; import core from './core'; -import {Channel, Character, Conversation as Interfaces} from './interfaces'; +import { Channel, Character, Conversation as Interfaces } from './interfaces'; import l from './localize'; import {CommandContext, isAction, isCommand, isWarn, parse as parseCommand} from './slash_commands'; import MessageType = Interfaces.Message.Type; import {EventBus} from './preview/event-bus'; import throat from 'throat'; import Bluebird from 'bluebird'; -import log from 'electron-log'; //tslint:disable-line:match-default-export-name +import log from 'electron-log'; +import isChannel = Interfaces.isChannel; +import isPrivate = Interfaces.isPrivate; //tslint:disable-line:match-default-export-name function createMessage(this: any, type: MessageType, sender: Character, text: string, time?: Date): Message { if(type === MessageType.Message && isAction(text)) { @@ -45,7 +47,7 @@ abstract class Conversation implements Interfaces.Conversation { // private loadedMore = false; adManager: AdManager; - protected static readonly conversationThroat = throat(1); // make sure user posting and ad posting won't get in each others' way + public static readonly conversationThroat = throat(1); // make sure user posting and ad posting won't get in each others' way constructor(readonly key: string, public _isPinned: boolean) { this.adManager = new AdManager(this); @@ -161,7 +163,7 @@ abstract class Conversation implements Interfaces.Conversation { protected static readonly POST_DELAY = 1250; - protected static async testPostDelay(): Promise<void> { + public static async testPostDelay(): Promise<void> { const lastPostDelta = Date.now() - core.cache.getLastPost().getTime(); // console.log('Last Post Delta', lastPostDelta, ((lastPostDelta < Conversation.POST_DELAY) && (lastPostDelta > 0))); @@ -691,9 +693,41 @@ export default function(this: any): Interfaces.State { const conversation = state.channelMap[data.channel.toLowerCase()]; if(conversation === undefined) return core.channels.leave(data.channel); if(char.isIgnored) return; + const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time); EventBus.$emit('channel-message', { message, channel: conversation }); await conversation.addMessage(message); +// message.type === MessageType.Message + if ( + (isPrivate(conversation) && core.state.settings.risingFilter.hidePrivateMessages) || + (isChannel(conversation) && conversation.channel.owner === '' && core.state.settings.risingFilter.hidePublicChannelMessages) || + (isChannel(conversation) && conversation.channel.owner !== '' && core.state.settings.risingFilter.hidePrivateChannelMessages) + ) { + const cachedProfile = core.cache.profileCache.getSync(char.name) || await core.cache.profileCache.get(char.name); + + if (cachedProfile && isPrivate(conversation) && core.state.settings.risingFilter.autoReply && !cachedProfile.match.autoResponded) { + cachedProfile.match.autoResponded = true; + + log.debug('filter.autoresponse', { name: char.name }); + + void Conversation.conversationThroat( + async() => { + await Conversation.testPostDelay(); + + // tslint:disable-next-line:prefer-template + const m = '[Automated message] Sorry, the player of this character has indicated that they are not interested in characters matching your profile. They will not see your message.\n\n' + + 'Need a filter for yourself? Try out [url=https://mrstallion.github.io/fchat-rising/]F-Chat Rising[/url]'; + + core.connection.send('PRI', {recipient: char.name, message: m}); + core.cache.markLastPostTime(); + } + ); + } + + if (cachedProfile && cachedProfile.match.isFiltered) { + return; + } + } const words = conversation.settings.highlightWords.slice(); if(conversation.settings.defaultHighlights) words.push(...core.state.settings.highlightWords); diff --git a/chat/core.ts b/chat/core.ts index dd0d766..a0387c7 100644 --- a/chat/core.ts +++ b/chat/core.ts @@ -8,6 +8,7 @@ import {Channel, Character, Connection, Conversation, Logs, Notifications, Setti import { AdCoordinatorGuest } from './ads/ad-coordinator-guest'; import { GeneralSettings } from '../electron/common'; import { SiteSession } from '../site/site-session'; +import _ from 'lodash'; function createBBCodeParser(): BBCodeParser { const parser = new BBCodeParser(); @@ -73,7 +74,14 @@ const data = { vue.$watch(getter, callback); }, async reloadSettings(): Promise<void> { - state._settings = Object.assign(new SettingsImpl(), await core.settingsStore.get('settings')); + const s = await core.settingsStore.get('settings'); + + state._settings = _.mergeWith(new SettingsImpl(), s, (oVal, sVal) => { + if (_.isArray(oVal) && _.isArray(sVal)) { + return sVal; + } + }); + const hiddenUsers = await core.settingsStore.get('hiddenUsers'); state.hiddenUsers = hiddenUsers !== undefined ? hiddenUsers : []; } diff --git a/chat/interfaces.ts b/chat/interfaces.ts index 747132b..9bbe2c1 100644 --- a/chat/interfaces.ts +++ b/chat/interfaces.ts @@ -3,6 +3,7 @@ import {Connection} from '../fchat'; import {Channel, Character} from '../fchat/interfaces'; import { AdManager } from './ads/ad-manager'; +import { SmartFilterSettings } from '../learn/filter/types'; export {Connection, Channel, Character} from '../fchat/interfaces'; export const userStatuses: ReadonlyArray<Character.Status> = ['online', 'looking', 'away', 'busy', 'dnd']; export const channelModes: ReadonlyArray<Channel.Mode> = ['chat', 'ads', 'both']; @@ -15,6 +16,7 @@ export namespace Conversation { readonly time: Date score: number; + filterMatch: boolean; } export interface EventMessage extends BaseMessage { @@ -221,6 +223,8 @@ export namespace Settings { readonly risingShowUnreadOfflineCount: boolean; readonly risingColorblindMode: boolean; + + readonly risingFilter: SmartFilterSettings; } } diff --git a/chat/message_view.ts b/chat/message_view.ts index 256de83..12c17b3 100644 --- a/chat/message_view.ts +++ b/chat/message_view.ts @@ -33,7 +33,8 @@ const userPostfix: {[key: number]: string | undefined} = { let classes = `message message-${Conversation.Message.Type[message.type].toLowerCase()}` + (separators ? ' message-block' : '') + (message.type !== Conversation.Message.Type.Event && message.sender.name === core.connection.character ? ' message-own' : '') + ((this.classes !== undefined) ? ` ${this.classes}` : '') + - ` ${this.scoreClasses}`; + ` ${this.scoreClasses}` + + ` ${this.filterClasses}`; if(message.type !== Conversation.Message.Type.Event) { children.push( (message.type === Conversation.Message.Type.Action) ? createElement('i', { class: 'message-pre fas fa-star' }) : '', @@ -71,6 +72,7 @@ export default class MessageView extends Vue { readonly logs?: true; scoreClasses = this.getMessageScoreClasses(this.message); + filterClasses = this.getMessageFilterClasses(this.message); scoreWatcher: (() => void) | null = ((this.message.type === Conversation.Message.Type.Ad) && (this.message.score === 0)) ? this.$watch('message.score', () => this.scoreUpdate()) @@ -91,11 +93,13 @@ export default class MessageView extends Vue { // @Watch('message.score') scoreUpdate(): void { - const oldClasses = this.scoreClasses; + const oldScoreClasses = this.scoreClasses; + const oldFilterClasses = this.filterClasses; this.scoreClasses = this.getMessageScoreClasses(this.message); + this.filterClasses = this.getMessageFilterClasses(this.message); - if (this.scoreClasses !== oldClasses) { + if (this.scoreClasses !== oldScoreClasses || this.filterClasses !== oldFilterClasses) { this.$forceUpdate(); } @@ -115,4 +119,11 @@ export default class MessageView extends Vue { return `message-score ${Score.getClasses(message.score as Scoring)}`; } + getMessageFilterClasses(message: Conversation.Message): string { + if (!message.filterMatch) { + return ''; + } + + return 'filter-match'; + } } diff --git a/learn/cache-manager.ts b/learn/cache-manager.ts index e1ac0fe..027e250 100644 --- a/learn/cache-manager.ts +++ b/learn/cache-manager.ts @@ -78,7 +78,7 @@ export class CacheManager { const c = await this.profileCache.get(name); if (c) { - this.updateAdScoringForProfile(c.character, c.match.matchScore); + this.updateAdScoringForProfile(c.character, c.match.matchScore, c.match.isFiltered); return; } } @@ -111,7 +111,7 @@ export class CacheManager { const c = await methods.characterData(name, -1, true); const r = await this.profileCache.register(c); - this.updateAdScoringForProfile(c, r.match.matchScore); + this.updateAdScoringForProfile(c, r.match.matchScore, r.match.isFiltered); return c; } catch (err) { @@ -122,7 +122,7 @@ export class CacheManager { } - updateAdScoringForProfile(c: ComplexCharacter, score: number): void { + updateAdScoringForProfile(c: ComplexCharacter, score: number, isFiltered: boolean): void { EventBus.$emit( 'character-score', { @@ -131,7 +131,7 @@ export class CacheManager { } ); - this.populateAllConversationsWithScore(c.character.name, score); + this.populateAllConversationsWithScore(c.character.name, score, isFiltered); } @@ -446,9 +446,10 @@ export class CacheManager { // } msg.score = p.match.matchScore; + msg.filterMatch = p.match.isFiltered; if (populateAll) { - this.populateAllConversationsWithScore(char.name, p.match.matchScore); + this.populateAllConversationsWithScore(char.name, p.match.matchScore, p.match.isFiltered); } } @@ -457,7 +458,7 @@ export class CacheManager { // tslint:disable-next-line: prefer-function-over-method - protected populateAllConversationsWithScore(characterName: string, score: number): void { + public populateAllConversationsWithScore(characterName: string, score: number, isFiltered: boolean): void { _.each( core.conversations.channelConversations, (ch: ChannelConversation) => { @@ -467,6 +468,7 @@ export class CacheManager { // console.log('Update score', score, ch.name, m.sender.name, m.text, m.id); m.score = score; + m.filterMatch = isFiltered; } } ); diff --git a/learn/filter/smart-filter.ts b/learn/filter/smart-filter.ts new file mode 100644 index 0000000..1792480 --- /dev/null +++ b/learn/filter/smart-filter.ts @@ -0,0 +1,235 @@ +import _ from 'lodash'; +import { Matcher } from '../matcher'; +import { BodyType, Build, Kink, Species, TagId } from '../matcher-types'; +import { SmartFilterSelection, SmartFilterSettings } from './types'; +import { Character } from '../../interfaces'; +import log from 'electron-log'; +import core from '../../chat/core'; + +export interface SmartFilterOpts { + name: string; + kinks?: Kink[], + bodyTypes?: BodyType[], + builds?: Build[], + species?: Species[] + isAnthro?: boolean; + isHuman?: boolean; +} + +export class SmartFilter { + constructor(private opts: SmartFilterOpts) {} + + test(c: Character): boolean { + const builds = this.testBuilds(c); + const bodyTypes = this.testBodyTypes(c); + const species = this.testSpecies(c); + const isAnthro = this.testIsAnthro(c); + const isHuman = this.testIsHuman(c); + const kinks = this.testKinks(c); + + const result = builds || bodyTypes || species || isAnthro || isHuman || kinks; + + log.debug('smart-filter.test', + { name: c.name, filterName: this.opts.name, result, builds, bodyTypes, species, isAnthro, isHuman, kinks }); + + return this.testBuilds(c) || this.testBodyTypes(c) || this.testSpecies(c) || this.testIsAnthro(c) || this.testIsHuman(c) || + this.testKinks(c); + } + + testKinks(c: Character): boolean { + if (!this.opts.kinks) { + return false; + } + + const score = _.reduce(this.opts.kinks, (curScore, kinkId) => { + const pref = Matcher.getKinkPreference(c, kinkId); + + if (pref) { + curScore.matches += 1; + curScore.score += pref; + } + + return curScore; + }, { score: 0, matches: 0 }); + + return score.matches >= 1 && score.score >= 1.0 + (Math.log((this.opts.kinks?.length || 0) + 1) / 2); + } + + testBuilds(c: Character): boolean { + if (!this.opts.builds) { + return false; + } + + const build = Matcher.getTagValueList(TagId.Build, c); + + return !!build && !!_.find(this.opts.builds || [], build); + } + + testBodyTypes(c: Character): boolean { + if (!this.opts.bodyTypes) { + return false; + } + + const bodyType = Matcher.getTagValueList(TagId.BodyType, c); + + return !!bodyType && !!_.find(this.opts.bodyTypes || [], bodyType); + } + + testSpecies(c: Character): boolean { + if (!this.opts.species) { + return false; + } + + const species = Matcher.species(c); + + return !!species && !!_.find(this.opts.species || [], species); + } + + testIsHuman(c: Character): boolean { + return !!this.opts.isHuman && (Matcher.isHuman(c) || false); + } + + testIsAnthro(c: Character): boolean { + return !!this.opts.isAnthro && (Matcher.isAnthro(c) || false); + } +} + + +export type SmartFilterCollection = { + [key in keyof SmartFilterSelection]: SmartFilter; +}; + +export const smartFilters: SmartFilterCollection = { + ageplay: new SmartFilter({ + name: 'ageplay', + kinks: [Kink.Ageplay, Kink.AgeProgression, Kink.AgeRegression, Kink.UnderageCharacters, Kink.Infantilism] + }), + + anthro: new SmartFilter({ + name: 'anthro', + isAnthro: true + }), + + feral: new SmartFilter({ + name: 'feral', + bodyTypes: [BodyType.Feral] + }), + + gore: new SmartFilter({ + name: 'gore', + kinks: [ + Kink.Abrasions, Kink.Castration, Kink.Death, Kink.Emasculation, Kink.ExecutionMurder, Kink.Gore, Kink.Impalement, Kink.Mutilation, + Kink.Necrophilia, Kink.NonsexualPain, Kink.NonsexualTorture, Kink.Nullification, Kink.ToothRemoval, Kink.WoundFucking, + Kink.Cannibalism, Kink.GenitalTorture + ] + }), + + human: new SmartFilter({ + name: 'human', + isHuman: true + }), + + hyper: new SmartFilter({ + name: 'kinks', + kinks: [Kink.HyperAsses, Kink.HyperBalls, Kink.HyperBreasts, Kink.HyperCocks, Kink.HyperFat, Kink.HyperMuscle, Kink.HyperVaginas, + Kink.HyperVoluptous, Kink.HyperMuscleGrowth, Kink.MacroAsses, Kink.MacroBalls, Kink.MacroBreasts, Kink.MacroCocks] + }), + + incest: new SmartFilter({ + name: 'incest', + kinks: [Kink.Incest, Kink.IncestParental, Kink.IncestSiblings, Kink.ParentChildPlay, Kink.ForcedIncest] + }), + + microMacro: new SmartFilter({ + name: 'microMacro', + kinks: [Kink.MacroAsses, Kink.MacroBalls, Kink.MacroBreasts, Kink.MacroCocks, Kink.Macrophilia, Kink.MegaMacro, Kink.Microphilia, + Kink.GrowthMacro, Kink.ShrinkingMicro, Kink.SizeDifferencesMicroMacro] + }), + + obesity: new SmartFilter({ + name: 'obesity', + builds: [Build.Tubby, Build.Obese, Build.Chubby] + }), + + pregnancy: new SmartFilter({ + name: 'pregnancy', + kinks: [Kink.AlternativePregnancy, Kink.AnalPregnancy, Kink.Birthing, Kink.ExtremePregnancy, Kink.MalePregnancy, Kink.Pregnancy] + }), + + pokemon: new SmartFilter({ + name: 'pokemon', + species: [Species.Pokemon] + }), + + rape: new SmartFilter({ + name: 'rape', + kinks: [Kink.PseudoRape, Kink.DubConsensual, Kink.Nonconsensual] + }), + + scat: new SmartFilter({ + name: 'scat', + kinks: [Kink.HyperScat, Kink.Scat, Kink.ScatTorture, Kink.Soiling, Kink.SwallowingFeces] + }), + + std: new SmartFilter({ + name: 'std', + kinks: [Kink.STDs] + }), + + taur: new SmartFilter({ + name: 'taur', + bodyTypes: [BodyType.Taur] + }), + + unclean: new SmartFilter({ + name: 'unclean', + kinks: [Kink.BelchingBurping, Kink.DirtyFeet, Kink.ExtremeMusk, Kink.Farting, Kink.Filth, Kink.Slob, Kink.Smegma, Kink.SwallowingVomit, + Kink.UnwashedMusk, Kink.Vomiting] + }), + + vore: new SmartFilter({ + name: 'vore', + kinks: [Kink.Absorption, Kink.AlternativeVore, Kink.AnalVore, Kink.Cannibalism, Kink.CockVore, Kink.CookingVore, Kink.Digestion, + Kink.Disposal, Kink.HardVore, Kink.RealisticVore, Kink.SoftVore, Kink.Unbirthing, Kink.UnrealisticVore, Kink.VoreBeingPredator, + Kink.VoreBeingPrey] + }), + + watersports: new SmartFilter({ + name: 'watersports', + kinks: [Kink.HyperWatersports, Kink.PissEnemas, Kink.SwallowingUrine, Kink.Watersports, Kink.Wetting] + }), + + zoophilia: new SmartFilter({ + name: 'zoophilia', + kinks: [Kink.Zoophilia, Kink.AnimalsFerals, Kink.Quadrupeds] + }) +}; + + +export function matchesSmartFilters(c: Character, opts: SmartFilterSettings): boolean { + if (c.name === core.characters.ownCharacter.name) { + return false; + } + + if (core.characters.get(c.name)?.isChatOp) { + return false; + } + + if (opts.exceptionNames.includes(c.name)) { + log.debug('smart-filter.exception', { name: c.name }); + return false; + } + + if (opts.minAge !== null || opts.maxAge !== null) { + const age = Matcher.age(c); + + if (age !== null) { + if ((opts.minAge !== null && age < opts.minAge) || (opts.maxAge !== null && age > opts.maxAge)) { + log.debug('smart-filter.age', { name: c.name, age, minAge: opts.minAge, maxAge: opts.maxAge }); + return true; + } + } + } + + return !_.every(opts.smartFilters, (fs, filterName) => !fs || !(smartFilters as any)[filterName].test(c)); +} diff --git a/learn/filter/types.ts b/learn/filter/types.ts new file mode 100644 index 0000000..8ede011 --- /dev/null +++ b/learn/filter/types.ts @@ -0,0 +1,47 @@ +// <!-- [Automated message] Sorry, the player of this character has indicated that they are not interested in characters matching your profile.--> +// <!-- Need a filter for yourself? Try out [F-Chat Rising](https://mrstallion.github.io/fchat-rising/)!--> + + +export const smartFilterTypes = { + ageplay: { name: 'Ageplay' }, + anthro: { name: 'Anthros' }, + feral: { name: 'Ferals' }, + gore: { name: 'Gore/torture/death' }, + human: { name: 'Humans' }, + hyper: { name: 'Hyper' }, + incest: { name: 'Incest' }, + microMacro: { name: 'Micro/macro' }, + obesity: { name: 'Obesity' }, + pokemon: { name: 'Pokemons/Digimons' }, + pregnancy: { name: 'Pregnancy' }, + rape: { name: 'Rape' }, + scat: { name: 'Scat' }, + std: { name: 'STDs' }, + taur: { name: 'Taurs' }, + vore: { name: 'Vore and unbirthing' }, + unclean: { name: 'Unclean' }, + watersports: { name: 'Watersports' }, + zoophilia: { name: 'Zoophilia' } +}; + +export type SmartFilterSelection = { + [key in keyof typeof smartFilterTypes]: boolean; +}; + +export interface SmartFilterSettings { + hideAds: boolean; + hideSearchResults: boolean; + hideChannelMembers: boolean; + hidePublicChannelMessages: boolean; + hidePrivateChannelMessages: boolean; + hidePrivateMessages: boolean; + penalizeMatches: boolean; + autoReply: boolean; + + minAge: number | null; + maxAge: number | null; + + smartFilters: SmartFilterSelection; + + exceptionNames: string[]; +} diff --git a/learn/matcher-types.ts b/learn/matcher-types.ts index 858e27e..d857838 100644 --- a/learn/matcher-types.ts +++ b/learn/matcher-types.ts @@ -87,6 +87,22 @@ export enum BodyType { Taur = 145 } +export enum Build { + Lithe = 12, + Thin = 14, + Slim = 15, + Average = 16, + Toned = 17, + Muscular = 18, + Buff = 19, + Herculean = 20, + Tubby = 21, + Obese = 22, + Curvy = 129, + Chubby = 200, + Varies = 201 +} + export enum KinkPreference { Favorite = 1, Yes = 0.5, @@ -113,7 +129,119 @@ export enum Kink { AnthroCharacters = 587, Humans = 609, - Mammals = 224 + Mammals = 224, + + Abrasions = 1, + Bloodplay = 4, + Branding = 492, + BreastNippleTorture = 36, + Burning = 21, + Castration = 20, + Death = 28, + Emasculation = 508, + ExecutionMurder = 717, + GenitalTorture = 276, + Gore = 689, + Impalement = 270, + Menses = 99, + Mutilation = 96, + Necrophilia = 308, + NonsexualPain = 486, + NonsexualTorture = 103, + Nullification = 334, + Piercing = 479, + SexualTorture = 174, + SwallowingBlood = 202, + ToothRemoval = 690, + WoundFucking = 691, + + HyperScat = 415, + Scat= 164, + ScatTorture = 369, + Soiling = 509, + SwallowingFeces = 201, + + HyperWatersports = 414, + PissEnemas = 533, + SwallowingUrine = 203, + Watersports = 59, + Wetting = 371, + + BelchingBurping = 709, + DirtyFeet = 706, + ExtremeMusk = 335, + Farting = 549, + Filth = 707, + Messy = 89, + Slob = 570, + Smegma = 708, + SwallowingVomit = 560, + UnwashedMusk = 705, + Vomiting = 184, + + Absorption = 239, + AlternativeVore = 244, + AnalVore = 209, + Cannibalism = 714, + CockVore = 208, + CookingVore = 716, + Digestion = 238, + Disposal = 241, + HardVore = 66, + RealisticVore = 242, + SoftVore = 73, + Unbirthing = 210, + UnrealisticVore = 243, + VoreBeingPredator = 422, + VoreBeingPrey = 423, + + AlternativePregnancy = 702, + AnalPregnancy = 704, + Birthing = 461, + ExtremePregnancy = 272, + MalePregnancy = 198, + Pregnancy = 154, + + STDs = 656, + + PseudoRape = 522, + DubConsensual = 657, + Nonconsensual = 100, + + Incest = 127, + IncestParental = 646, + IncestSiblings = 647, + ParentChildPlay = 304, + ForcedIncest = 53, + + AgeProgression = 622, + AgeRegression = 621, + Infantilism = 497, + + Zoophilia = 218, + AnimalsFerals = 487, + Quadrupeds = 382, + + HyperAsses = 595, + HyperBalls = 233, + HyperBreasts = 594, + HyperCocks = 60, + HyperFat = 377, + HyperMuscle = 376, + HyperVaginas = 593, + HyperVoluptous = 378, + HyperMuscleGrowth = 389, + + MacroAsses = 596, + MacroBalls = 550, + MacroBreasts = 91, + MacroCocks = 61, + Macrophilia = 285, + MegaMacro = 374, + Microphilia = 286, + SizeDifferencesMicroMacro = 502, + GrowthMacro = 384, + ShrinkingMicro = 387 } export enum FurryPreference { diff --git a/learn/matcher.ts b/learn/matcher.ts index 0c7753c..4410dc4 100644 --- a/learn/matcher.ts +++ b/learn/matcher.ts @@ -1235,9 +1235,17 @@ export class Matcher { + _.values(m.them.scores).length; } + static age(c: Character): number | null { + const rawAge = Matcher.getTagValue(TagId.Age, c); + const age = ((rawAge) && (rawAge.string)) ? parseInt(rawAge.string, 10) : null; + + return age && !Number.isNaN(age) && Number.isFinite(age) ? age : null; + } + static calculateSearchScoreForMatch( score: Scoring, - match: MatchReport + match: MatchReport, + penalty: number ): number { const totalScoreDimensions = match ? Matcher.countScoresTotal(match) : 0; const dimensionsAtScoreLevel = match ? (Matcher.countScoresAtLevel(match, score) || 0) : 0; @@ -1289,10 +1297,11 @@ export class Matcher { dimensionsAboveScoreLevel, dimensionsAtScoreLevel, theirAtLevelDimensions, - theirAboveLevelDimensions + theirAboveLevelDimensions, + penalty } ); - return (atLevelScore + aboveLevelScore); + return (atLevelScore + aboveLevelScore + penalty); } } diff --git a/learn/profile-cache.ts b/learn/profile-cache.ts index 7cdee53..0d3823b 100644 --- a/learn/profile-cache.ts +++ b/learn/profile-cache.ts @@ -7,6 +7,7 @@ import { Matcher, MatchReport } from './matcher'; import { PermanentIndexedStore } from './store/types'; import { CharacterImage, SimpleCharacter } from '../interfaces'; import { Scoring } from './matcher-types'; +import { matchesSmartFilters } from './filter/smart-filter'; export interface MetaRecord { @@ -30,6 +31,8 @@ export interface CharacterMatchSummary { // dimensionsAboveScoreLevel: number; // totalScoreDimensions: number; searchScore: number; + isFiltered: boolean; + autoResponded?: boolean; } export interface CharacterCacheRecord { @@ -52,6 +55,11 @@ export class ProfileCache extends AsyncCache<CharacterCacheRecord> { } + onEachInMemory(cb: (c: CharacterCacheRecord, key: string) => void): void { + _.each(this.cache, cb); + } + + getSync(name: string): CharacterCacheRecord | null { const key = AsyncCache.nameKey(name); @@ -153,8 +161,13 @@ export class ProfileCache extends AsyncCache<CharacterCacheRecord> { // const totalScoreDimensions = match ? Matcher.countScoresTotal(match) : 0; // const dimensionsAtScoreLevel = match ? (Matcher.countScoresAtLevel(match, score) || 0) : 0; // const dimensionsAboveScoreLevel = match ? (Matcher.countScoresAboveLevel(match, Math.max(score, Scoring.WEAK_MATCH))) : 0; - const searchScore = match ? Matcher.calculateSearchScoreForMatch(score, match) : 0; - const matchDetails = { matchScore: score, searchScore }; + const isFiltered = matchesSmartFilters(c.character, core.state.settings.risingFilter); + + const searchScore = match + ? Matcher.calculateSearchScoreForMatch(score, match, isFiltered && core.state.settings.risingFilter.penalizeMatches ? -2 : 0) + : 0; + + const matchDetails = { matchScore: score, searchScore, isFiltered }; if ((this.store) && (!skipStore)) { await this.store.storeProfile(c);