UNPKG

@gramio/callback-data

Version:

Library for easily manage callback-data

297 lines (293 loc) 9.09 kB
import { createHash } from 'node:crypto'; class CompactSerializer { static serialize(schema, obj) { const parts = []; for (const field of schema.required) { parts.push(this.serializeValue(field, obj[field.key])); } let bitmask = 0; const optionalParts = []; for (let i = 0; i < schema.optional.length; i++) { const field = schema.optional[i]; if (Object.prototype.hasOwnProperty.call(obj, field.key)) { bitmask |= 1 << i; optionalParts.push(this.serializeValue(field, obj[field.key])); } } let bitmaskEncoded = ""; if (schema.optional.length > 0) { bitmaskEncoded = bitmask.toString(36); } return [ ...parts, ...schema.optional.length > 0 ? [bitmaskEncoded] : [], ...optionalParts ].join(";"); } static deserialize(schema, str) { const parts = str.split(";"); let ptr = 0; const result = {}; for (const field of schema.required) { result[field.key] = this.deserializeValue(field, parts[ptr++]); } let bitmask = 0; if (schema.optional.length > 0) { bitmask = Number.parseInt(parts[ptr++], 36); } for (let i = 0; i < schema.optional.length; i++) { const field = schema.optional[i]; if (bitmask & 1 << i) { result[field.key] = this.deserializeValue(field, parts[ptr++]); } else if (field.default !== void 0) { result[field.key] = field.default; } else { console.error("missing", field.key, "at", ptr); } } if (ptr !== parts.length) { throw new Error( `Invalid serialized data: Expected ${parts.length} parts, processed ${ptr}` ); } return result; } // TODO: rewrite and cleanup static ESCAPE_MAP = { ";": "\\s", // escape separator "\\": "\\\\", // escape escaping symbol "=": "\\e" // escape = }; // private static REVERT_ESCAPE_MAP: Record<string, string> = Object.fromEntries( // Object.entries(CompactSerializer.ESCAPE_MAP).map(([k, v]) => [v, k]), // ); static UNESCAPE_REGEX = /\\(\\|s|e)/g; static serializeValue(field, value) { switch (field.type) { case "number": if (Number.isSafeInteger(value)) { return value.toString(36); } return value.toString(); case "enum": return field.enumValues.indexOf(value).toString(36); case "uuid": { const hex = value.replace(/-/g, ""); const buffer = Buffer.from(hex, "hex"); return buffer.toString("base64url"); } case "string": { const str = value; if (str.length === 0) { throw new Error( `Invalid string value: Empty string at '${field.key}'` ); } if (!/[;\\=]/.test(str)) return str; return str.replace(/[;\\=]/g, (m) => this.ESCAPE_MAP[m]); } case "boolean": return value ? "1" : "0"; default: throw new Error(`Unsupported type: ${field.type}`); } } static deserializeValue(field, value) { switch (field.type) { case "number": if (/^-?[0-9a-z]+$/.test(value)) { return Number.parseInt(value, 36); } return Number.parseFloat(value); case "enum": { if (!field.enumValues) { throw new Error(`Missing enumValues for field '${field.key}'`); } const index = Number.parseInt(value, 36); if (index < 0 || index >= field.enumValues.length) { throw new Error(`Invalid index ${index} for enum '${field.key}'`); } return field.enumValues[index]; } case "uuid": { const buffer = Buffer.from(value, "base64url"); const hex = buffer.toString("hex"); return [ hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32) ].join("-").toLowerCase(); } case "string": { return value.replace( this.UNESCAPE_REGEX, (_, code) => code === "s" ? ";" : code === "e" ? "=" : "\\" ); } case "boolean": return value === "1"; default: throw new Error(`Unsupported type: ${field.type}`); } } } class CallbackData { /** Pass the `id` with which you can identify the CallbackData */ constructor(nameId) { this.nameId = nameId; this.id = createHash("sha1").update(nameId).digest("base64url").replace(/[_-]/g, "").slice(0, 6); this.legacyId = createHash("md5").update(nameId).digest("hex").slice(0, 6); } /** `id` for identify the CallbackData */ id; legacyId; // /** Schema used for serialize/deserialize with {@link CallbackData.pack} and {@link CallbackData.unpack} methods */ // schema: Record<string, Field> = {}; schema = { optional: [], required: [] }; /** * Add `string` property to schema * @param key Name of property */ string(key, options) { const isOptional = options?.optional ?? options?.default !== void 0; this.schema[isOptional ? "optional" : "required"].push({ key, type: "string", default: options?.default }); return this; } /** * Add `number` property to schema * @param key Name of property */ number(key, options) { const isOptional = options?.optional ?? options?.default !== void 0; this.schema[isOptional ? "optional" : "required"].push({ key, type: "number", default: options?.default }); return this; } /** * Add `boolean` property to schema * @param key Name of property */ boolean(key, options) { const isOptional = options?.optional ?? options?.default !== void 0; this.schema[isOptional ? "optional" : "required"].push({ key, type: "boolean", default: options?.default }); return this; } /** * Add `enum` property to schema * @param key Name of property * @param enumValues Enum values */ enum(key, enumValues, options) { const isOptional = options?.optional ?? options?.default !== void 0; this.schema[isOptional ? "optional" : "required"].push({ key, type: "enum", enumValues, default: options?.default }); return this; } /** * Add `uuid` property to schema * @param key Name of property */ uuid(key, options) { const isOptional = options?.optional ?? options?.default !== void 0; this.schema[isOptional ? "optional" : "required"].push({ key, type: "uuid", default: options?.default }); return this; } /** * Method that return {@link RegExp} to match this {@link CallbackData} */ regexp() { return new RegExp(`^${this.id}|${this.legacyId}\\|(.+)$`); } /** * Method that return `true` if data is this {@link CallbackData} * @param data String with data */ filter(data) { return data.startsWith(this.id) || data.startsWith(`${this.legacyId}|`); } /** * A method for [`serializing`](https://developer.mozilla.org/en-US/docs/Glossary/Serialization) **object data** defined by **schema** into a **string** * @param data Object defined by schema * * @example * ```ts * const someData = new CallbackData("example").number("id"); * * context.send("some", { * reply_markup: new InlineKeyboard().text( * "example", * someData.pack({ * id: 1, * }), * ), * }); * ``` */ pack(...args) { const data = args[0] ?? {}; return `${this.id}${Object.keys(data).length > 0 ? CompactSerializer.serialize(this.schema, data) : ""}`; } /** * A method for [`deserializing`](https://developer.mozilla.org/en-US/docs/Glossary/Deserialization) data **object** by **schema** from a **string** * @param data String with data (please check that this string matched by {@link CallbackData.regexp}) */ unpack(data) { if (data.startsWith(`${this.legacyId}|`)) { const json = JSON.parse(data.replace(`${this.legacyId}|`, "")); return json; } const separatorIndex = data.indexOf(this.id); if (separatorIndex === -1) throw new Error( `You should call unpack only if you use filter(data) method to determine that data is this CallbackData. Currently, unpack is called for '${this.nameId}' with data '${data}'` ); const slicedData = data.slice(separatorIndex + this.id.length); if (slicedData.length === 0) { const hasDefault = this.schema.optional.some( (x) => x.default !== void 0 ); if (!hasDefault) { if (this.schema.required.length === 0) return {}; throw new Error( `Invalid serialized data: Expected ${this.schema.required.length} parts, processed ${this.schema.required.length}` ); } } return CompactSerializer.deserialize(this.schema, slicedData); } extend(other) { this.schema = { required: [...this.schema.required, ...other.schema.required], optional: [...this.schema.optional, ...other.schema.optional] }; return this; } } export { CallbackData };