diff --git a/public/index.html b/public/index.html
index f7f726422..b5a2eda86 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5397,26 +5397,6 @@
diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js
index 93a54baea..371b233c3 100644
--- a/public/scripts/world-info.js
+++ b/public/scripts/world-info.js
@@ -1,5 +1,5 @@
import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
-import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, equalsIgnoreCaseAndAccents, getSanitizedFilename, checkOverwriteExistingData, parseStringArray } from './utils.js';
+import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray } from './utils.js';
import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { isMobile } from './RossAscends-mods.js';
@@ -84,24 +84,39 @@ const DEFAULT_DEPTH = 4;
const DEFAULT_WEIGHT = 100;
const MAX_SCAN_DEPTH = 1000;
+// Typedef area
+/**
+ * @typedef {object} WIScanEntry The entry that triggered the scan
+ * @property {number} [scanDepth] The depth of the scan
+ * @property {boolean} [caseSensitive] If the scan is case sensitive
+ * @property {boolean} [matchWholeWords] If the scan should match whole words
+ * @property {boolean} [useGroupScoring] If the scan should use group scoring
+ * @property {number} [uid] The UID of the entry that triggered the scan
+ * @property {string} [world] The world info book of origin of the entry
+ * @property {string[]} [key] The primary keys to scan for
+ * @property {string[]} [keysecondary] The secondary keys to scan for
+ * @property {number} [selectiveLogic] The logic to use for selective activation
+ * @property {number} [sticky] The sticky value of the entry
+ * @property {number} [cooldown] The cooldown of the entry
+ */
+
+/**
+ * @typedef {object} WITimedEffect Timed effect for world info
+ * @property {number} hash Hash of the entry that triggered the effect
+ * @property {number} start The chat index where the effect starts
+ * @property {number} end The chat index where the effect ends
+ */
+
+/**
+ * @typedef TimedEffectType Type of timed effect
+ * @type {'sticky'|'cooldown'}
+ */
+// End typedef area
+
/**
* Represents a scanning buffer for one evaluation of World Info.
*/
class WorldInfoBuffer {
- // Typedef area
- /**
- * @typedef {object} WIScanEntry The entry that triggered the scan
- * @property {number} [scanDepth] The depth of the scan
- * @property {boolean} [caseSensitive] If the scan is case sensitive
- * @property {boolean} [matchWholeWords] If the scan should match whole words
- * @property {boolean} [useGroupScoring] If the scan should use group scoring
- * @property {number} [uid] The UID of the entry that triggered the scan
- * @property {string[]} [key] The primary keys to scan for
- * @property {string[]} [keysecondary] The secondary keys to scan for
- * @property {number} [selectiveLogic] The logic to use for selective activation
- */
- // End typedef area
-
/**
* @type {object[]} Array of entries that need to be activated no matter what
*/
@@ -266,9 +281,9 @@ class WorldInfoBuffer {
}
/**
- * Clears the force activations buffer.
+ * Clean-up the external effects for entries.
*/
- cleanExternalActivations() {
+ resetExternalEffects() {
WorldInfoBuffer.externalActivations.splice(0, WorldInfoBuffer.externalActivations.length);
}
@@ -325,6 +340,300 @@ class WorldInfoBuffer {
}
}
+/**
+ * Represents a timed effects manager for World Info.
+ */
+class WorldInfoTimedEffects {
+ /**
+ * Cache for entry hashes. Uses weak map to avoid memory leaks.
+ * @type {WeakMap}
+ */
+ #entryHashCache = new WeakMap();
+
+ /**
+ * Array of chat messages.
+ * @type {string[]}
+ */
+ #chat = [];
+
+ /**
+ * Array of entries.
+ * @type {WIScanEntry[]}
+ */
+ #entries = [];
+
+ /**
+ * Buffer for active timed effects.
+ * @type {Record}
+ */
+ #buffer = {
+ 'sticky': [],
+ 'cooldown': [],
+ };
+
+ /**
+ * Callbacks for effect types ending.
+ * @type {Record void>}
+ */
+ #onEnded = {
+ /**
+ * Callback for when a sticky entry ends.
+ * Sets an entry on cooldown immediately if it has a cooldown.
+ * @param {WIScanEntry} entry Entry that ended sticky
+ */
+ 'sticky': (entry) => {
+ if (!entry.cooldown) {
+ return;
+ }
+
+ const key = this.#getEntryKey(entry);
+ const effect = this.#getEntryTimedEffect(entry, 'cooldown');
+ chat_metadata.timedWorldInfo.cooldown[key] = effect;
+ console.log(`Adding cooldown entry ${key} on ended sticky: start=${effect.start}, end=${effect.end}`);
+ // Set the cooldown immediately for this evaluation
+ this.#buffer['cooldown'].push(entry);
+ },
+
+ /**
+ * Callback for when a cooldown entry ends.
+ * No-op, essentially.
+ * @param {WIScanEntry} entry Entry that ended cooldown
+ */
+ 'cooldown': (entry) => {
+ console.debug('Cooldown ended for entry', entry.uid);
+ },
+ };
+
+ /**
+ * Initialize the timed effects with the given messages.
+ * @param {string[]} chat Array of chat messages
+ * @param {WIScanEntry[]} entries Array of entries
+ */
+ constructor(chat, entries) {
+ this.#chat = chat;
+ this.#entries = entries;
+ this.#ensureChatMetadata();
+ }
+
+ /**
+ * Verify correct structure of chat metadata.
+ */
+ #ensureChatMetadata() {
+ if (!chat_metadata.timedWorldInfo) {
+ chat_metadata.timedWorldInfo = {};
+ }
+
+ ['sticky', 'cooldown'].forEach(type => {
+ // Ensure the property exists and is an object
+ if (!chat_metadata.timedWorldInfo[type] || typeof chat_metadata.timedWorldInfo[type] !== 'object') {
+ chat_metadata.timedWorldInfo[type] = {};
+ }
+
+ // Clean up invalid entries
+ Object.entries(chat_metadata.timedWorldInfo[type]).forEach(([key, value]) => {
+ if (!value || typeof value !== 'object') {
+ delete chat_metadata.timedWorldInfo[type][key];
+ }
+ });
+ });
+ }
+
+ /**
+ * Gets a hash for a WI entry.
+ * @param {WIScanEntry} entry WI entry
+ * @returns {number} String hash
+ */
+ #getEntryHash(entry) {
+ if (this.#entryHashCache.has(entry)) {
+ return this.#entryHashCache.get(entry);
+ }
+
+ const hash = getStringHash(JSON.stringify(entry));
+ this.#entryHashCache.set(entry, hash);
+ return hash;
+ }
+
+ /**
+ * Gets a unique-ish key for a WI entry.
+ * @param {WIScanEntry} entry WI entry
+ * @returns {string} String key for the entry
+ */
+ #getEntryKey(entry) {
+ return `${entry.world}.${entry.uid}`;
+ }
+
+ /**
+ * Gets a timed effect for a WI entry.
+ * @param {WIScanEntry} entry WI entry
+ * @param {TimedEffectType} type Type of timed effect
+ * @returns {WITimedEffect} Timed effect for the entry
+ */
+ #getEntryTimedEffect(entry, type) {
+ return {
+ hash: this.#getEntryHash(entry),
+ start: this.#chat.length,
+ end: this.#chat.length + Number(entry[type]),
+ };
+ }
+
+ /**
+ * Processes entries for a given type of timed effect.
+ * @param {TimedEffectType} type Identifier for the type of timed effect
+ * @param {WIScanEntry[]} buffer Buffer to store the entries
+ * @param {(entry: WIScanEntry) => void} onEnded Callback for when a timed effect ends
+ */
+ #checkTimedEffectOfType(type, buffer, onEnded) {
+ /** @type {[string, WITimedEffect][]} */
+ const effects = Object.entries(chat_metadata.timedWorldInfo[type]);
+ for (const [key, value] of effects) {
+ console.log(`Processing ${type} entry ${key}`, value);
+ const entry = this.#entries.find(x => String(this.#getEntryHash(x)) === String(value.hash));
+
+ if (this.#chat.length <= Number(value.start)) {
+ console.log(`Removing ${type} entry ${key} from timedWorldInfo: chat not advanced`, value);
+ delete chat_metadata.timedWorldInfo[type][key];
+ continue;
+ }
+
+ // Missing entries (they could be from another character's lorebook)
+ if (!entry) {
+ if (this.#chat.length > Number(value.end)) {
+ console.log(`Removing ${type} entry from timedWorldInfo: entry not found and interval passed`, entry);
+ delete chat_metadata.timedWorldInfo[type][key];
+ }
+ continue;
+ }
+
+ // Ignore invalid entries (not configured for timed effects)
+ if (!entry[type]) {
+ console.log(`Removing ${type} entry from timedWorldInfo: entry not ${type}`, entry);
+ delete chat_metadata.timedWorldInfo[type][key];
+ continue;
+ }
+
+ if (this.#chat.length > Number(value.end)) {
+ console.log(`Removing ${type} entry from timedWorldInfo: ${type} interval passed`, entry);
+ delete chat_metadata.timedWorldInfo[type][key];
+ if (typeof onEnded === 'function') {
+ onEnded(entry);
+ }
+ continue;
+ }
+
+ buffer.push(entry);
+ console.log(`Timed effect "${type}" applied to entry`, entry);
+ }
+ }
+
+ /**
+ * Checks for timed effects on chat messages.
+ */
+ checkTimedEffects() {
+ this.#checkTimedEffectOfType('sticky', this.#buffer.sticky, this.#onEnded.sticky.bind(this));
+ this.#checkTimedEffectOfType('cooldown', this.#buffer.cooldown, this.#onEnded.cooldown.bind(this));
+ }
+
+ /**
+ * Gets raw timed effect metadatum for a WI entry.
+ * @param {TimedEffectType} type Type of timed effect
+ * @param {WIScanEntry} entry WI entry
+ * @returns {WITimedEffect} Timed effect for the entry
+ */
+ getEffectMetadata(type, entry) {
+ if (!this.isValidEffectType(type)) {
+ return null;
+ }
+
+ const key = this.#getEntryKey(entry);
+ return chat_metadata.timedWorldInfo[type][key];
+ }
+
+ /**
+ * Sets a timed effect for a WI entry.
+ * @param {TimedEffectType} type Type of timed effect
+ * @param {WIScanEntry} entry WI entry to check
+ */
+ #setTimedEffectOfType(type, entry) {
+ // Skip if entry does not have the type (sticky or cooldown)
+ if (!entry[type]) {
+ return;
+ }
+
+ const key = this.#getEntryKey(entry);
+
+ if (!chat_metadata.timedWorldInfo[type][key]) {
+ const effect = this.#getEntryTimedEffect(entry, type);
+ chat_metadata.timedWorldInfo[type][key] = effect;
+
+ console.log(`Adding ${type} entry ${key}: start=${effect.start}, end=${effect.end}`);
+ }
+ }
+
+ /**
+ * Sets timed effects on chat messages.
+ * @param {WIScanEntry[]} activatedEntries Entries that were activated
+ */
+ setTimedEffects(activatedEntries) {
+ for (const entry of activatedEntries) {
+ this.#setTimedEffectOfType('sticky', entry);
+ this.#setTimedEffectOfType('cooldown', entry);
+ }
+ }
+
+ /**
+ * Force set a timed effect for a WI entry.
+ * @param {TimedEffectType} type Type of timed effect
+ * @param {WIScanEntry} entry WI entry
+ * @param {boolean} newState The state of the effect
+ */
+ setTimedEffect(type, entry, newState) {
+ if (!this.isValidEffectType(type)) {
+ return;
+ }
+
+ const key = this.#getEntryKey(entry);
+ delete chat_metadata.timedWorldInfo[type][key];
+
+ if (newState) {
+ const effect = this.#getEntryTimedEffect(entry, type);
+ chat_metadata.timedWorldInfo[type][key] = effect;
+ console.log(`Adding ${type} entry ${key}: start=${effect.start}, end=${effect.end}`);
+ }
+ }
+
+ /**
+ * Check if the string is a valid timed effect type.
+ * @param {string} type Name of the timed effect
+ * @returns {boolean} Is recognized type
+ */
+ isValidEffectType(type) {
+ return typeof type === 'string' && ['sticky', 'cooldown'].includes(type.trim().toLowerCase());
+ }
+
+ /**
+ * Check if the current entry is sticky activated.
+ * @param {TimedEffectType} type Type of timed effect
+ * @param {WIScanEntry} entry WI entry to check
+ * @returns {boolean} True if the entry is active
+ */
+ isEffectActive(type, entry) {
+ if (!this.isValidEffectType(type)) {
+ return false;
+ }
+
+ return this.#buffer[type]?.some(x => this.#getEntryHash(x) === this.#getEntryHash(entry)) ?? false;
+ }
+
+ /**
+ * Clean-up previously set timed effects.
+ */
+ cleanUp() {
+ for (const buffer of Object.values(this.#buffer)) {
+ buffer.splice(0, buffer.length);
+ }
+ }
+}
+
export function getWorldInfoSettings() {
return {
world_info,
@@ -357,7 +666,7 @@ export const wi_anchor_position = {
after: 1,
};
-const worldInfoCache = {};
+const worldInfoCache = new Map();
/**
* Gets the world info based on chat messages.
@@ -370,7 +679,7 @@ const worldInfoCache = {};
async function getWorldInfoPrompt(chat, maxContext, isDryRun) {
let worldInfoString = '', worldInfoBefore = '', worldInfoAfter = '';
- const activatedWorldInfo = await checkWorldInfo(chat, maxContext);
+ const activatedWorldInfo = await checkWorldInfo(chat, maxContext, isDryRun);
worldInfoBefore = activatedWorldInfo.worldInfoBefore;
worldInfoAfter = activatedWorldInfo.worldInfoAfter;
worldInfoString = worldInfoBefore + worldInfoAfter;
@@ -477,9 +786,11 @@ function setWorldInfoSettings(settings, data) {
$('#world_info').trigger('change');
$('#world_editor_select').trigger('change');
- eventSource.on(event_types.CHAT_CHANGED, () => {
+ eventSource.on(event_types.CHAT_CHANGED, async () => {
const hasWorldInfo = !!chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY]);
$('.chat_lorebook_button').toggleClass('world_set', hasWorldInfo);
+ // Pre-cache the world info data for the chat for quicker first prompt generation
+ await getSortedEntries();
});
eventSource.on(event_types.WORLDINFO_FORCE_ACTIVATE, (entries) => {
@@ -498,6 +809,16 @@ function registerWorldInfoSlashCommands() {
}
}
+ /**
+ * Gets a *rough* approximation of the current chat context.
+ * Normally, it is provided externally by the prompt builder.
+ * Don't use for anything critical!
+ * @returns {string[]}
+ */
+ function getScanningChat() {
+ return getContext().chat.filter(x => !x.is_system).map(x => x.mes);
+ }
+
async function getEntriesFromFile(file) {
if (!file || !world_names.includes(file)) {
toastr.warning('Valid World Info file name is required');
@@ -701,6 +1022,116 @@ function registerWorldInfoSlashCommands() {
return '';
}
+ async function getTimedEffectCallback(args, value) {
+ if (!getCurrentChatId()) {
+ throw new Error('This command can only be used in chat');
+ }
+
+ const file = args.file;
+ const uid = value;
+ const effect = args.effect;
+
+ const entries = await getEntriesFromFile(file);
+
+ if (!entries) {
+ return '';
+ }
+
+ /** @type {WIScanEntry} */
+ const entry = structuredClone(entries.find(x => String(x.uid) === String(uid)));
+
+ if (!entry) {
+ toastr.warning('Valid UID is required');
+ return '';
+ }
+
+ entry.world = file; // Required by the timed effects manager
+ const chat = getScanningChat();
+ const timedEffects = new WorldInfoTimedEffects(chat, [entry]);
+
+ if (!timedEffects.isValidEffectType(effect)) {
+ toastr.warning('Valid effect type is required');
+ return '';
+ }
+
+ const data = timedEffects.getEffectMetadata(effect, entry);
+
+ if (String(args.format).trim().toLowerCase() === ARGUMENT_TYPE.NUMBER) {
+ return String(data ? (data.end - chat.length) : 0);
+ }
+
+ return String(!!data);
+ }
+
+ async function setTimedEffectCallback(args, value) {
+ if (!getCurrentChatId()) {
+ throw new Error('This command can only be used in chat');
+ }
+
+ const file = args.file;
+ const uid = args.uid;
+ const effect = args.effect;
+
+ if (value === undefined) {
+ toastr.warning('New state is required');
+ return '';
+ }
+
+ const entries = await getEntriesFromFile(file);
+
+ if (!entries) {
+ return '';
+ }
+
+ /** @type {WIScanEntry} */
+ const entry = structuredClone(entries.find(x => String(x.uid) === String(uid)));
+
+ if (!entry) {
+ toastr.warning('Valid UID is required');
+ return '';
+ }
+
+ entry.world = file; // Required by the timed effects manager
+ const chat = getScanningChat();
+ const timedEffects = new WorldInfoTimedEffects(chat, [entry]);
+
+ if (!timedEffects.isValidEffectType(effect)) {
+ toastr.warning('Valid effect type is required');
+ return '';
+ }
+
+ if (!entry[effect]) {
+ toastr.warning('This entry does not have the selected effect. Configure it in the editor first.');
+ return '';
+ }
+
+ const getNewEffectState = () => {
+ const currentState = !!timedEffects.getEffectMetadata(effect, entry);
+
+ if (['toggle', 't', ''].includes(value.trim().toLowerCase())) {
+ return !currentState;
+ }
+
+ if (isTrueBoolean(value)) {
+ return true;
+ }
+
+ if (isFalseBoolean(value)) {
+ return false;
+ }
+
+ return currentState;
+ };
+
+ const newEffectState = getNewEffectState();
+ timedEffects.setTimedEffect(effect, entry, newEffectState);
+
+ await saveMetadata();
+ toastr.success(`Timed effect "${effect}" for entry ${entry.uid} is now ${newEffectState ? 'active' : 'inactive'}`);
+
+ return '';
+ }
+
/** A collection of local enum providers for this context of world info */
const localEnumProviders = {
/** All possible fields that can be set in a WI entry */
@@ -713,12 +1144,18 @@ function registerWorldInfoSlashCommands() {
const file = executor.namedArgumentList.find(it => it.name == 'file')?.value;
if (file instanceof SlashCommandClosure) throw new Error('Argument \'file\' does not support closures');
// Try find world from cache
- const world = worldInfoCache[file];
+ if (!worldInfoCache.has(file)) return [];
+ const world = worldInfoCache.get(file);
if (!world) return [];
return Object.entries(world.entries).map(([uid, data]) =>
new SlashCommandEnumValue(uid, `${data.comment ? `${data.comment}: ` : ''}${data.key.join(', ')}${data.keysecondary?.length ? ` [${Object.entries(world_info_logic).find(([_, value]) => value == data.selectiveLogic)[0]}] ${data.keysecondary.join(', ')}` : ''} [${getWiPositionString(data)}]`,
enumTypes.enum, enumIcons.getWiStatusIcon(data)));
},
+
+ timedEffects: () => [
+ new SlashCommandEnumValue('sticky', 'Stays active for N messages', enumTypes.enum, '📌'),
+ new SlashCommandEnumValue('cooldown', 'Cooldown for N messages', enumTypes.enum, '⌛'),
+ ],
};
function getWiPositionString(entry) {
@@ -734,7 +1171,8 @@ function registerWorldInfoSlashCommands() {
}
}
- SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'world',
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'world',
callback: onWorldInfoChange,
namedArgumentList: [
new SlashCommandNamedArgument(
@@ -758,14 +1196,16 @@ function registerWorldInfoSlashCommands() {
`,
aliases: [],
}));
- SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getchatbook',
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'getchatbook',
callback: getChatBookCallback,
returns: 'lorebook name',
helpString: 'Get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe.',
aliases: ['getchatlore', 'getchatwi'],
}));
- SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'findentry',
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'findentry',
aliases: ['findlore', 'findwi'],
returns: 'UID',
callback: findBookEntryCallback,
@@ -804,7 +1244,8 @@ function registerWorldInfoSlashCommands() {
`,
}));
- SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getentryfield',
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'getentryfield',
aliases: ['getlorefield', 'getwifield'],
callback: getEntryFieldCallback,
returns: 'field value',
@@ -846,7 +1287,8 @@ function registerWorldInfoSlashCommands() {
`,
}));
- SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'createentry',
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'createentry',
callback: createEntryCallback,
aliases: ['createlore', 'createwi'],
returns: 'UID of the new record',
@@ -881,7 +1323,8 @@ function registerWorldInfoSlashCommands() {
`,
}));
- SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'setentryfield',
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'setentryfield',
callback: setEntryFieldCallback,
aliases: ['setlorefield', 'setwifield'],
namedArgumentList: [
@@ -926,7 +1369,110 @@ function registerWorldInfoSlashCommands() {
`,
}));
-
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'wi-set-timed-effect',
+ callback: setTimedEffectCallback,
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'file',
+ description: 'book name',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ enumProvider: commonEnumProviders.worlds,
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'uid',
+ description: 'record UID',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ enumProvider: localEnumProviders.wiUids,
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'effect',
+ description: 'effect name',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ enumProvider: localEnumProviders.timedEffects,
+ }),
+ ],
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: 'new state of the effect',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ acceptsMultiple: false,
+ enumList: commonEnumProviders.boolean('onOffToggle')(),
+ }),
+ ],
+ helpString: `
+
+ Set a timed effect for the record with the UID from the specified book. The duration must be set in the entry itself.
+ Will only be applied for the current chat. Enabling an effect that was already active refreshes the duration.
+ If the last chat message is swiped or deleted, the effect will be removed.
+
+
+ Get the current state of the timed effect for the record with the UID from the specified book.
+
+