@gramio/callback-data
Version:
Library for easily manage callback-data
297 lines (293 loc) • 9.09 kB
JavaScript
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 };