| 1 | import { seedrandom, droll } from '../../../lib.js'; |
| 2 | import { chat_metadata, main_api, getMaxPromptTokens, getMaxContextTokens, getMaxResponseTokens, extension_prompts, getCurrentChatId } from '../../../script.js'; |
| 3 | import { getStringHash, isFalseBoolean } from '../../utils.js'; |
| 4 | import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js'; |
| 5 | import { inject_ids } from '../../constants.js'; |
| 6 | import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js'; |
| 7 | import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../engine/MacroLexer.js'; |
| 8 | import { MacroParser } from '../engine/MacroParser.js'; |
| 9 | import { MacroCstWalker } from '../engine/MacroCstWalker.js'; |
| 10 | |
| 11 | /** |
| 12 | * Marker used by {{else}} to split content in {{if}} blocks. |
| 13 | * Uses control characters to minimize collision with real content. |
| 14 | * |
| 15 | * This marker is used internally by the macro engine to separate if/else branches. |
| 16 | * It should never appear in user-generated content. |
| 17 | * |
| 18 | * @type {string} |
| 19 | */ |
| 20 | export const ELSE_MARKER = '\u0000\u001FELSE\u001F\u0000'; |
| 21 | |
| 22 | /** |
| 23 | * Registers SillyTavern's core built-in macros in the MacroRegistry. |
| 24 | * |
| 25 | * These macros correspond to the main {{...}} macros that are available |
| 26 | * in prompts (time/date/chat info, utility macros, etc.). They are |
| 27 | * intended to preserve the behavior of the existing regex-based macros |
| 28 | * in macros.js while using the new MacroRegistry/MacroEngine pipeline. |
| 29 | */ |
| 30 | export function registerCoreMacros() { |
| 31 | // {{space}} -> ' ' |
| 32 | MacroRegistry.registerMacro('space', { |
| 33 | category: MacroCategory.UTILITY, |
| 34 | unnamedArgs: [ |
| 35 | { |
| 36 | name: 'count', |
| 37 | optional: true, |
| 38 | defaultValue: '1', |
| 39 | type: MacroValueType.INTEGER, |
| 40 | description: 'Number of spaces to insert.', |
| 41 | }, |
| 42 | ], |
| 43 | description: 'Returns one or more spaces. One space by default, more if the count argument is specified.', |
| 44 | returns: 'One or more spaces.', |
| 45 | exampleUsage: ['{{space}}', '{{space::4}}'], |
| 46 | handler: ({ unnamedArgs: [count] }) => ' '.repeat(Number(count ?? 1)), |
| 47 | }); |
| 48 | |
| 49 | // {{newline}} -> '\n' |
| 50 | MacroRegistry.registerMacro('newline', { |
| 51 | category: MacroCategory.UTILITY, |
| 52 | unnamedArgs: [ |
| 53 | { |
| 54 | name: 'count', |
| 55 | optional: true, |
| 56 | defaultValue: '1', |
| 57 | type: MacroValueType.INTEGER, |
| 58 | description: 'Number of newlines to insert.', |
| 59 | }, |
| 60 | ], |
| 61 | description: 'Inserts one or more newlines. One newline by default, more if the count argument is specified.', |
| 62 | returns: 'One or more \\n.', |
| 63 | exampleUsage: ['{{newline}}', '{{newline::2}}'], |
| 64 | handler: ({ unnamedArgs: [count] }) => '\n'.repeat(Number(count ?? 1)), |
| 65 | }); |
| 66 | |
| 67 | // {{noop}} -> '' |
| 68 | MacroRegistry.registerMacro('noop', { |
| 69 | category: MacroCategory.UTILITY, |
| 70 | description: 'Does nothing and produces an empty string.', |
| 71 | returns: '', |
| 72 | handler: () => '', |
| 73 | }); |
| 74 | |
| 75 | // {{trim}} -> macro will currently replace itself with itself. Trimming is handled in post-processing. |
| 76 | // Scoped: {{trim}}content{{/trim}} -> trims whitespace from content (handled by engine auto-trim) |
| 77 | MacroRegistry.registerMacro('trim', { |
| 78 | category: MacroCategory.UTILITY, |
| 79 | description: 'Trims whitespace. Non-scoped: trims newlines around the macro (post-processing). Scoped: returns the content (auto-trimmed by the engine).', |
| 80 | unnamedArgs: [ |
| 81 | { |
| 82 | name: 'content', |
| 83 | description: 'Content to trim (when used as scoped macro)', |
| 84 | optional: true, |
| 85 | }, |
| 86 | ], |
| 87 | returns: '', |
| 88 | handler: ({ unnamedArgs: [content], isScoped }) => { |
| 89 | // Scoped usage: return content (already auto-trimmed by the engine) |
| 90 | if (isScoped) return content ?? ''; |
| 91 | // Non-scoped: return marker for post-processing regex |
| 92 | return '{{trim}}'; |
| 93 | }, |
| 94 | }); |
| 95 | |
| 96 | /** |
| 97 | * Splits raw content on the first {{else}} macro at nesting depth 0. |
| 98 | * Tracks scoped {{if}}/{{/if}} pairs to find the correct top-level else. |
| 99 | * Only {{if}} with 1 argument (condition only) are considered scoped blocks. |
| 100 | * |
| 101 | * @param {string} content - The raw content to split |
| 102 | * @returns {{ thenBranch: string, elseBranch: string | undefined }} |
| 103 | */ |
| 104 | function splitOnTopLevelElse(content) { |
| 105 | const { cst } = MacroParser.parseDocument(content); |
| 106 | const macroNodes = /** @type {import('chevrotain').CstNode[]} */ (cst?.children?.macro || []); |
| 107 | |
| 108 | let depth = 0; |
| 109 | for (const macroNode of macroNodes) { |
| 110 | const info = MacroCstWalker.extractMacroInfo(macroNode); |
| 111 | if (!info) continue; |
| 112 | |
| 113 | // Only track scoped {{if}} blocks (1 arg = condition only, expects {{/if}}) |
| 114 | // Inline {{if condition::content}} has 2 args and doesn't affect depth |
| 115 | if (info.name === 'if' && !info.isClosing && info.argCount === 1) { |
| 116 | depth++; |
| 117 | } else if (info.name === 'if' && info.isClosing) { |
| 118 | depth--; |
| 119 | } else if (info.name === 'else' && depth === 0) { |
| 120 | return { |
| 121 | thenBranch: content.slice(0, info.startOffset), |
| 122 | elseBranch: content.slice(info.endOffset + 1), |
| 123 | }; |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | return { thenBranch: content, elseBranch: undefined }; |
| 128 | } |
| 129 | |
| 130 | // {{if condition}}content{{/if}} -> conditional content |
| 131 | // {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch |
| 132 | // {{if !condition}}content{{/if}} -> inverted conditional (negated) |
| 133 | // Condition can be a macro name (resolved automatically), variable shorthand (.var or $var), or any value |
| 134 | MacroRegistry.registerMacro('if', { |
| 135 | category: MacroCategory.UTILITY, |
| 136 | description: 'Conditional macro. Returns the content if the condition is truthy, otherwise returns nothing (or the else branch if present). Prefix the condition with ! to invert. If the condition is a registered macro name (without braces), it will be resolved first. Variable shorthands (.varname for local, $varname for global) are also supported.', |
| 137 | unnamedArgs: [ |
| 138 | { |
| 139 | name: 'condition', |
| 140 | description: 'The condition to evaluate. Prefix with ! to invert. Can be a macro name (auto-resolved), variable shorthand (.var or $var), or a value. Falsy: empty string, "false", "off", "0".', |
| 141 | }, |
| 142 | { |
| 143 | name: 'content', |
| 144 | description: 'The content to return if condition is truthy (typically provided as scoped content). May contain {{else}} to define an else branch.', |
| 145 | }, |
| 146 | ], |
| 147 | displayOverride: '{{if condition}}then{{else}}other{{/if}}', |
| 148 | exampleUsage: [ |
| 149 | '{{if description}}# Description\n{{description}}{{/if}}', |
| 150 | '{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}', |
| 151 | '{{if !personality}}No personality defined{{/if}}', |
| 152 | '{{if {{getvar::showHeader}}}}# Header{{/if}}', |
| 153 | '{{if .myvar}}Local var exists{{/if}}', |
| 154 | '{{if $globalFlag}}Global flag is set{{/if}}', |
| 155 | ], |
| 156 | returns: 'The content if condition is truthy, else branch or empty string otherwise.', |
| 157 | // Delay argument resolution so nested macros are only evaluated in the chosen branch |
| 158 | delayArgResolution: true, |
| 159 | handler: ({ unnamedArgs: [rawCondition, rawContent], flags, resolve, trimContent }) => { |
| 160 | // With delayArgResolution: true, args contain raw (unresolved) text. |
| 161 | // We resolve the condition first, then only resolve the chosen branch. |
| 162 | |
| 163 | // Check if the condition starts with ! for inversion |
| 164 | let inverted = false; |
| 165 | let condition = rawCondition; |
| 166 | if (/^\s*!/.test(rawCondition)) { |
| 167 | inverted = true; |
| 168 | condition = rawCondition.replace(/^\s*!\s*/, ''); |
| 169 | } |
| 170 | |
| 171 | // Resolve the condition (may contain nested macros like {{getvar::x}}) |
| 172 | condition = resolve(condition); |
| 173 | |
| 174 | // Check if condition is a variable shorthand (.varname or $varname) |
| 175 | // If so, resolve it using the appropriate variable macro |
| 176 | const varShorthandRegex = new RegExp(`^([.$])(${MACRO_VARIABLE_SHORTHAND_PATTERN.source})$`); |
| 177 | const varShorthandMatch = condition.match(varShorthandRegex); |
| 178 | if (varShorthandMatch) { |
| 179 | const [, prefix, varName] = varShorthandMatch; |
| 180 | const varMacro = prefix === '.' ? 'getvar' : 'getglobalvar'; |
| 181 | condition = resolve(`{{${varMacro}::${varName}}}`); |
| 182 | } else { |
| 183 | // Check if condition is a registered macro name (without braces) |
| 184 | // If so, resolve it first (only for macros that accept 0 required args) |
| 185 | const macroDef = MacroRegistry.getPrimaryMacro(condition); |
| 186 | if (macroDef && macroDef.minArgs === 0) { |
| 187 | condition = resolve(`{{${condition}}}`); |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // Check if condition is falsy: empty string or isFalseBoolean |
| 192 | let isFalsy = condition === '' || isFalseBoolean(condition); |
| 193 | if (inverted) isFalsy = !isFalsy; |
| 194 | |
| 195 | // Split raw content on {{else}} macro at the top nesting level |
| 196 | // We need to track nesting depth to find the correct {{else}} for this if |
| 197 | const { thenBranch, elseBranch } = splitOnTopLevelElse(rawContent); |
| 198 | |
| 199 | // Only resolve the chosen branch |
| 200 | const chosenBranch = !isFalsy ? thenBranch : elseBranch; |
| 201 | if (chosenBranch === undefined) { |
| 202 | return ''; |
| 203 | } |
| 204 | |
| 205 | // Resolve nested macros in the chosen branch |
| 206 | // Trim result unless # flag is set (preserveWhitespace) |
| 207 | let result = resolve(chosenBranch); |
| 208 | if (!flags.preserveWhitespace) { |
| 209 | result = trimContent(result); |
| 210 | } |
| 211 | return result; |
| 212 | }, |
| 213 | }); |
| 214 | |
| 215 | // {{else}} -> marker for else branch inside {{if}} blocks |
| 216 | // Only meaningful inside a scoped {{if}} macro |
| 217 | MacroRegistry.registerMacro('else', { |
| 218 | category: MacroCategory.UTILITY, |
| 219 | description: 'Marks the else branch inside a scoped {{if}} block. Only works inside {{if}}...{{/if}}. If used outside, returns an invisible marker.', |
| 220 | exampleUsage: [ |
| 221 | '{{if condition}}true branch{{else}}false branch{{/if}}', |
| 222 | ], |
| 223 | returns: 'Invisible marker (consumed by the enclosing {{if}} macro).', |
| 224 | handler: () => ELSE_MARKER, |
| 225 | }); |
| 226 | |
| 227 | // {{input}} -> current textarea content |
| 228 | MacroRegistry.registerMacro('input', { |
| 229 | category: MacroCategory.UTILITY, |
| 230 | description: 'Current text from the send textarea.', |
| 231 | returns: 'Current text from the send textarea.', |
| 232 | handler: () => (/** @type {HTMLTextAreaElement} */(document.querySelector('#send_textarea')))?.value ?? '', |
| 233 | }); |
| 234 | |
| 235 | // {{maxPrompt}} -> max context size (context minus response) |
| 236 | MacroRegistry.registerMacro('maxPrompt', { |
| 237 | aliases: [{ alias: 'maxPromptTokens', visible: true }], |
| 238 | category: MacroCategory.STATE, |
| 239 | description: 'Maximum prompt context size.', |
| 240 | returns: 'Maximum prompt context size.', |
| 241 | returnType: MacroValueType.INTEGER, |
| 242 | handler: () => String(getMaxPromptTokens()), |
| 243 | }); |
| 244 | |
| 245 | // {{maxContext}} -> max context token limit |
| 246 | MacroRegistry.registerMacro('maxContext', { |
| 247 | aliases: [{ alias: 'maxContextTokens', visible: true }], |
| 248 | category: MacroCategory.STATE, |
| 249 | description: 'Maximum context token limit.', |
| 250 | returns: 'Maximum context token limit.', |
| 251 | returnType: MacroValueType.INTEGER, |
| 252 | handler: () => String(getMaxContextTokens()), |
| 253 | }); |
| 254 | |
| 255 | // {{maxResponse}} -> max response token limit |
| 256 | MacroRegistry.registerMacro('maxResponse', { |
| 257 | aliases: [{ alias: 'maxResponseTokens', visible: true }], |
| 258 | category: MacroCategory.STATE, |
| 259 | description: 'Maximum response token limit.', |
| 260 | returns: 'Maximum response token limit.', |
| 261 | returnType: MacroValueType.INTEGER, |
| 262 | handler: () => String(getMaxResponseTokens()), |
| 263 | }); |
| 264 | |
| 265 | // String utilities |
| 266 | MacroRegistry.registerMacro('reverse', { |
| 267 | category: MacroCategory.UTILITY, |
| 268 | unnamedArgs: [ |
| 269 | { |
| 270 | name: 'value', |
| 271 | type: MacroValueType.STRING, |
| 272 | description: 'The string to reverse.', |
| 273 | }, |
| 274 | ], |
| 275 | description: 'Reverses the characters of the argument provided.', |
| 276 | returns: 'Reversed string.', |
| 277 | exampleUsage: ['{{reverse::I am Lana}}'], |
| 278 | handler: ({ unnamedArgs: [value] }) => Array.from(value).reverse().join(''), |
| 279 | }); |
| 280 | |
| 281 | // Comment macro: {{// ...}} -> '' (consumes any arguments) |
| 282 | MacroRegistry.registerMacro('//', { |
| 283 | aliases: [{ alias: 'comment', visible: false }], |
| 284 | category: MacroCategory.UTILITY, |
| 285 | unnamedArgs: [ |
| 286 | { |
| 287 | name: 'comment', |
| 288 | type: MacroValueType.STRING, |
| 289 | description: 'Any kind of text as comment. If you want multiline comments, consider using a scoped macro like {{//}}First\nSecond{{///}}.', |
| 290 | }, |
| 291 | ], |
| 292 | list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway |
| 293 | strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid |
| 294 | description: 'Comment macro that produces an empty string. Can be used for writing into prompt definitions, without being passed to the context.', |
| 295 | returns: '', |
| 296 | displayOverride: '{{// ...}}', |
| 297 | exampleUsage: ['{{// This is a comment}}'], |
| 298 | handler: () => '', |
| 299 | }); |
| 300 | |
| 301 | // Time and date macros |
| 302 | // Dice roll macro: {{roll 1d6}} or {{roll: 1d6}} |
| 303 | MacroRegistry.registerMacro('roll', { |
| 304 | category: MacroCategory.RANDOM, |
| 305 | unnamedArgs: [ |
| 306 | { |
| 307 | name: 'formula', |
| 308 | sampleValue: '1d20', |
| 309 | description: 'Dice roll formula using droll syntax (e.g. 1d20).', |
| 310 | type: 'string', |
| 311 | }, |
| 312 | ], |
| 313 | description: 'Rolls dice using droll syntax (e.g. {{roll 1d20}}).', |
| 314 | returns: 'Dice roll result.', |
| 315 | returnType: MacroValueType.INTEGER, |
| 316 | exampleUsage: [ |
| 317 | '{{roll::1d20}}', |
| 318 | '{{roll::6}}', |
| 319 | '{{roll::3d6+4}}', |
| 320 | ], |
| 321 | handler: ({ unnamedArgs: [formula], warn }) => { |
| 322 | // If only digits were provided, treat it as `1dX`. |
| 323 | if (/^\d+$/.test(formula)) { |
| 324 | formula = `1d${formula}`; |
| 325 | } |
| 326 | |
| 327 | const isValid = droll.validate(formula); |
| 328 | if (!isValid) { |
| 329 | warn(`Invalid roll formula: ${formula}`); |
| 330 | return ''; |
| 331 | } |
| 332 | |
| 333 | const result = droll.roll(formula); |
| 334 | if (result === false) return ''; |
| 335 | return String(result.total); |
| 336 | }, |
| 337 | }); |
| 338 | |
| 339 | // Random choice macro: {{random::a::b}} or {{random a,b}} |
| 340 | MacroRegistry.registerMacro('random', { |
| 341 | category: MacroCategory.RANDOM, |
| 342 | list: true, |
| 343 | description: 'Picks a random item from a list. Will be re-rolled every time macros are resolved.', |
| 344 | returns: 'Randomly selected item from the list.', |
| 345 | exampleUsage: ['{{random::blonde::brown::red::black::blue}}'], |
| 346 | handler: ({ list }) => { |
| 347 | // Handle old legacy cases, where we have to split the list manually |
| 348 | if (list.length === 1) { |
| 349 | list = readSingleArgsRandomList(list[0]); |
| 350 | } |
| 351 | |
| 352 | if (list.length === 0) { |
| 353 | return ''; |
| 354 | } |
| 355 | |
| 356 | const rng = seedrandom('added entropy.', { entropy: true }); |
| 357 | const randomIndex = Math.floor(rng() * list.length); |
| 358 | return list[randomIndex]; |
| 359 | }, |
| 360 | }); |
| 361 | |
| 362 | // Deterministic choice macro: {{pick::a::b}} or {{pick a,b}} |
| 363 | MacroRegistry.registerMacro('pick', { |
| 364 | category: MacroCategory.RANDOM, |
| 365 | list: true, |
| 366 | description: 'Picks a random item from a list, but keeps the choice stable for a given chat and macro position. Can be rerolled via /reroll-pick slash command.', |
| 367 | // TODO: add expanded documentation once HTML details are supported |
| 368 | // descriptionDetails: ` |
| 369 | // <p>Picks a random item from a list, but keeps the choice stable for a given chat and macro position.</p> |
| 370 | // <p>The choice can be reset per chat using the <code>/reroll-pick</code> slash command.</p> |
| 371 | // `, |
| 372 | returns: 'Stable randomly selected item from the list.', |
| 373 | exampleUsage: ['{{pick::blonde::brown::red::black::blue}}'], |
| 374 | handler: ({ list, globalOffset, env }) => { |
| 375 | // Handle old legacy cases, where we have to split the list manually |
| 376 | if (list.length === 1) { |
| 377 | list = readSingleArgsRandomList(list[0]); |
| 378 | } |
| 379 | |
| 380 | if (!list.length) { |
| 381 | return ''; |
| 382 | } |
| 383 | |
| 384 | // NOTE: |
| 385 | // When changing the hashing logic, make sure to update unit test functionality |
| 386 | // in registerTestablePick() to be identical. |
| 387 | |
| 388 | const chatIdHash = getChatIdHash(); |
| 389 | |
| 390 | // Use the full original input string for deterministic behavior |
| 391 | const rawContentHash = env.contentHash; |
| 392 | |
| 393 | // Use globalOffset for deterministic seeding - this ensures identical macros |
| 394 | // at different positions in the document produce different results, even when |
| 395 | // nested inside arguments or scoped content |
| 396 | const offset = globalOffset; |
| 397 | |
| 398 | // Reroll seed allows users to reset all picks in the chat via /reroll-pick command |
| 399 | const rerollSeed = chat_metadata.pick_reroll_seed || null; |
| 400 | |
| 401 | const combinedSeedString = [chatIdHash, rawContentHash, offset, rerollSeed].filter(it => it !== null).join('-'); |
| 402 | const finalSeed = getStringHash(combinedSeedString); |
| 403 | const rng = seedrandom(String(finalSeed)); |
| 404 | const randomIndex = Math.floor(rng() * list.length); |
| 405 | return list[randomIndex]; |
| 406 | }, |
| 407 | }); |
| 408 | |
| 409 | /** @param {string} listString @return {string[]} */ |
| 410 | function readSingleArgsRandomList(listString) { |
| 411 | // If it contains double colons, those will have precedence over comma-separated lists. |
| 412 | // This can only happen if the macro only had a single colon to introduce the list... |
| 413 | // like, {{random:a::b::c}} |
| 414 | if (listString.includes('::')) { |
| 415 | return listString.split('::').map((/** @type {string} */ item) => item.trim()); |
| 416 | } |
| 417 | // Otherwise, we fall back and split by commas that may be present |
| 418 | return listString |
| 419 | .replace(/\\,/g, '##�COMMA�##') |
| 420 | .split(',') |
| 421 | .map((/** @type {string} */ item) => item.trim().replace(/##�COMMA�##/g, ',')); |
| 422 | } |
| 423 | |
| 424 | // Banned words macro: {{banned "word"}} |
| 425 | MacroRegistry.registerMacro('banned', { |
| 426 | category: MacroCategory.UTILITY, |
| 427 | unnamedArgs: [ |
| 428 | { |
| 429 | name: 'word', |
| 430 | sampleValue: 'word', |
| 431 | description: 'Word to ban for Text Completion backend.', |
| 432 | type: 'string', |
| 433 | }, |
| 434 | ], |
| 435 | description: 'Bans a word for Text Completion backend. (Strips quotes surrounding the banned word, if present)', |
| 436 | returns: '', |
| 437 | exampleUsage: ['{{banned::delve}}'], |
| 438 | handler: ({ unnamedArgs: [bannedWord] }) => { |
| 439 | // Strip quotes via regex, which were allowed in legacy syntax |
| 440 | bannedWord = bannedWord.replace(/^"|"$/g, ''); |
| 441 | if (main_api === 'textgenerationwebui') { |
| 442 | console.log('Found banned word in macros: ' + bannedWord); |
| 443 | textgenerationwebui_banned_in_macros.push(bannedWord); |
| 444 | } |
| 445 | return ''; |
| 446 | }, |
| 447 | }); |
| 448 | |
| 449 | // Outlet macro: {{outlet::key}} |
| 450 | MacroRegistry.registerMacro('outlet', { |
| 451 | category: MacroCategory.UTILITY, |
| 452 | unnamedArgs: [ |
| 453 | { |
| 454 | name: 'key', |
| 455 | sampleValue: 'my-outlet-key', |
| 456 | description: 'Outlet key.', |
| 457 | type: 'string', |
| 458 | }, |
| 459 | ], |
| 460 | description: 'Returns the world info outlet prompt for a given outlet key.', |
| 461 | returns: 'World info outlet prompt.', |
| 462 | exampleUsage: ['{{outlet::character-achievements}}'], |
| 463 | handler: ({ unnamedArgs: [outlet] }) => { |
| 464 | if (!outlet) return ''; |
| 465 | const value = extension_prompts[inject_ids.CUSTOM_WI_OUTLET(outlet)]?.value; |
| 466 | return value || ''; |
| 467 | }, |
| 468 | }); |
| 469 | } |
| 470 | |
| 471 | function getChatIdHash() { |
| 472 | const cachedIdHash = chat_metadata.chat_id_hash; |
| 473 | if (typeof cachedIdHash === 'number') { |
| 474 | return cachedIdHash; |
| 475 | } |
| 476 | |
| 477 | const chatId = chat_metadata.main_chat ?? getCurrentChatId(); |
| 478 | const chatIdHash = getStringHash(chatId); |
| 479 | chat_metadata.chat_id_hash = chatIdHash; |
| 480 | return chatIdHash; |
| 481 | } |