2024-08-13 04:53:07 +03:00
|
|
|
import type {
|
2024-07-06 22:12:13 +03:00
|
|
|
BusinessCallbackQuery,
|
|
|
|
CallbackQuery,
|
|
|
|
InlineCallbackQuery,
|
|
|
|
MaybeArray,
|
|
|
|
MaybePromise,
|
2024-08-13 04:53:07 +03:00
|
|
|
} from '@mtcute/core'
|
|
|
|
import {
|
2024-07-06 22:12:13 +03:00
|
|
|
MtArgumentError,
|
|
|
|
} from '@mtcute/core'
|
2022-06-30 16:32:56 +03:00
|
|
|
|
2024-08-13 04:53:07 +03:00
|
|
|
import type { UpdateFilter } from './filters/types.js'
|
2021-06-20 01:29:40 +03:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Callback data builder, inspired by [aiogram](https://github.com/aiogram/aiogram).
|
|
|
|
*
|
|
|
|
* This can be used to simplify management of different callbacks.
|
|
|
|
*
|
2022-08-29 16:22:57 +03:00
|
|
|
* [Learn more in the docs](/guide/topics/keyboards.html#callback-data-builders)
|
2021-06-20 01:29:40 +03:00
|
|
|
*/
|
|
|
|
export class CallbackDataBuilder<T extends string> {
|
|
|
|
private readonly _fields: T[]
|
|
|
|
|
|
|
|
sep = ':'
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param prefix Prefix for the data. Use something unique across your bot.
|
|
|
|
* @param fields Field names in the order they will be serialized.
|
|
|
|
*/
|
2023-10-06 21:15:52 +03:00
|
|
|
constructor(
|
|
|
|
public prefix: string,
|
|
|
|
...fields: T[]
|
|
|
|
) {
|
2021-06-20 01:29:40 +03:00
|
|
|
this._fields = fields
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Build a callback data string
|
|
|
|
*
|
|
|
|
* @param obj Object containing the data
|
|
|
|
*/
|
|
|
|
build(obj: Record<T, string>): string {
|
2024-08-13 04:53:07 +03:00
|
|
|
const ret
|
|
|
|
= this.prefix
|
|
|
|
+ this.sep
|
|
|
|
+ this._fields
|
2021-06-20 01:29:40 +03:00
|
|
|
.map((f) => {
|
|
|
|
const val = obj[f]
|
|
|
|
|
2023-09-03 02:37:51 +03:00
|
|
|
if (val.includes(this.sep)) {
|
2021-08-05 20:38:24 +03:00
|
|
|
throw new MtArgumentError(
|
2023-06-05 03:30:48 +03:00
|
|
|
`Value for ${f} ${val} contains separator ${this.sep} and cannot be used.`,
|
2021-06-20 01:29:40 +03:00
|
|
|
)
|
2023-06-05 03:30:48 +03:00
|
|
|
}
|
2021-06-20 01:29:40 +03:00
|
|
|
|
|
|
|
return val
|
|
|
|
})
|
|
|
|
.join(this.sep)
|
|
|
|
|
|
|
|
if (ret.length > 64) {
|
2022-06-30 16:32:56 +03:00
|
|
|
throw new MtArgumentError('Resulting callback data is too long.')
|
2021-06-20 01:29:40 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
return ret
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Parse callback data to object
|
|
|
|
*
|
|
|
|
* @param data Callback data as string
|
2024-05-28 14:25:59 +03:00
|
|
|
* @param safe If `true`, will return `null` instead of throwing on invalid data
|
2021-06-20 01:29:40 +03:00
|
|
|
*/
|
2024-05-28 14:25:59 +03:00
|
|
|
parse(data: string, safe?: false): Record<T, string>
|
|
|
|
parse(data: string, safe: true): Record<T, string> | null
|
|
|
|
parse(data: string, safe = false): Record<T, string> | null {
|
2021-06-20 01:29:40 +03:00
|
|
|
const parts = data.split(this.sep)
|
|
|
|
|
|
|
|
if (parts[0] !== this.prefix) {
|
2024-05-28 14:25:59 +03:00
|
|
|
if (safe) return null
|
2024-05-28 14:21:10 +03:00
|
|
|
throw new MtArgumentError(
|
|
|
|
`Invalid data passed: "${data}" (bad prefix, expected ${this.prefix}, got ${parts[0]})`,
|
|
|
|
)
|
2021-06-20 01:29:40 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
if (parts.length !== this._fields.length + 1) {
|
2024-05-28 14:25:59 +03:00
|
|
|
if (safe) return null
|
2024-05-28 14:21:10 +03:00
|
|
|
throw new MtArgumentError(
|
|
|
|
`Invalid data passed: "${data}" (bad parts count, expected ${this._fields.length}, got ${
|
|
|
|
parts.length - 1
|
|
|
|
})`,
|
|
|
|
)
|
2021-06-20 01:29:40 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const ret = {} as Record<T, string>
|
|
|
|
parts.forEach((it, idx) => {
|
2023-11-19 22:53:39 +03:00
|
|
|
if (idx === 0) return // skip prefix
|
|
|
|
|
2021-06-20 01:29:40 +03:00
|
|
|
ret[this._fields[idx - 1]] = it
|
|
|
|
})
|
2023-06-05 03:30:48 +03:00
|
|
|
|
2021-06-20 01:29:40 +03:00
|
|
|
return ret
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create a filter for this callback data.
|
|
|
|
*
|
2024-05-27 00:06:31 +03:00
|
|
|
* You can either pass an object with field names as keys and values as strings or regexes,
|
|
|
|
* which will be compiled to a RegExp, or a function that will be called with the parsed data.
|
|
|
|
* Note that the strings will be passed to `RegExp` **directly**, so you may want to escape them.
|
|
|
|
*
|
|
|
|
* When using a function, you can either return a boolean, or an object with field names as keys
|
|
|
|
* and values as strings or regexes. In the latter case, the resulting object will be matched
|
|
|
|
* against the parsed data the same way as if you passed it directly.
|
2021-06-20 01:29:40 +03:00
|
|
|
*
|
|
|
|
* @param params
|
|
|
|
*/
|
2024-07-06 22:12:13 +03:00
|
|
|
filter<Update extends CallbackQuery | InlineCallbackQuery | BusinessCallbackQuery>(
|
2024-05-27 00:06:31 +03:00
|
|
|
params:
|
|
|
|
| ((
|
2024-08-13 04:53:07 +03:00
|
|
|
upd: Update,
|
|
|
|
parsed: Record<T, string>,
|
|
|
|
) => MaybePromise<Partial<Record<T, MaybeArray<string | RegExp>>> | boolean>)
|
2024-05-27 00:06:31 +03:00
|
|
|
| Partial<Record<T, MaybeArray<string | RegExp>>> = {},
|
|
|
|
): UpdateFilter<
|
2024-08-13 04:53:07 +03:00
|
|
|
Update,
|
|
|
|
{
|
|
|
|
match: Record<T, string>
|
|
|
|
}
|
|
|
|
> {
|
2024-05-27 00:06:31 +03:00
|
|
|
if (typeof params === 'function') {
|
|
|
|
return async (query) => {
|
|
|
|
if (!query.dataStr) return false
|
|
|
|
|
2024-05-28 14:25:59 +03:00
|
|
|
const data = this.parse(query.dataStr, true)
|
|
|
|
if (!data) return false
|
|
|
|
|
2024-05-27 00:06:31 +03:00
|
|
|
const fnResult = await params(query, data)
|
|
|
|
|
|
|
|
if (typeof fnResult === 'boolean') {
|
|
|
|
(
|
2024-06-15 18:12:51 +03:00
|
|
|
query as Update & {
|
2024-05-27 00:06:31 +03:00
|
|
|
match: Record<T, string>
|
|
|
|
}
|
|
|
|
).match = data
|
|
|
|
|
|
|
|
return fnResult
|
|
|
|
}
|
|
|
|
|
|
|
|
// validate result
|
|
|
|
for (const key in fnResult) {
|
|
|
|
const value = data[key]
|
|
|
|
if (value === undefined) return false
|
|
|
|
|
|
|
|
let matchers = fnResult[key] as MaybeArray<string | RegExp>
|
|
|
|
if (!Array.isArray(matchers)) matchers = [matchers]
|
|
|
|
|
|
|
|
for (const matcher of matchers) {
|
|
|
|
if (typeof matcher === 'string') {
|
|
|
|
if (value !== matcher) return false
|
2024-08-13 04:53:07 +03:00
|
|
|
} else if (!matcher.test(value)) {
|
|
|
|
return false
|
|
|
|
}
|
2024-05-27 00:06:31 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
(
|
2024-06-15 18:12:51 +03:00
|
|
|
query as Update & {
|
2024-05-27 00:06:31 +03:00
|
|
|
match: Record<T, string>
|
|
|
|
}
|
|
|
|
).match = data
|
|
|
|
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-06-20 01:29:40 +03:00
|
|
|
const parts: string[] = []
|
|
|
|
|
|
|
|
this._fields.forEach((field) => {
|
|
|
|
if (!(field in params)) {
|
|
|
|
parts.push(`[^${this.sep}]*?`)
|
2023-06-05 03:30:48 +03:00
|
|
|
|
2021-06-20 01:29:40 +03:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-06-05 03:30:48 +03:00
|
|
|
const value = params[field]
|
|
|
|
|
2021-06-20 01:29:40 +03:00
|
|
|
if (Array.isArray(value)) {
|
2024-08-13 04:53:07 +03:00
|
|
|
parts.push(`(${value.map(i => (typeof i === 'string' ? i : i.source)).join('|')})`)
|
2021-06-20 01:29:40 +03:00
|
|
|
} else {
|
2022-06-30 16:32:56 +03:00
|
|
|
// noinspection SuspiciousTypeOfGuard
|
2023-09-24 01:32:22 +03:00
|
|
|
parts.push(typeof value === 'string' ? value : (value as RegExp).source)
|
2021-06-20 01:29:40 +03:00
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2023-09-24 01:32:22 +03:00
|
|
|
const regex = new RegExp(`^${this.prefix}${this.sep}${parts.join(this.sep)}$`)
|
2021-06-20 01:29:40 +03:00
|
|
|
|
|
|
|
return (query) => {
|
|
|
|
const m = query.dataStr?.match(regex)
|
2024-08-13 04:53:07 +03:00
|
|
|
if (!m) {
|
|
|
|
return false
|
|
|
|
}(
|
2024-06-15 18:12:51 +03:00
|
|
|
query as Update & {
|
2022-06-30 16:32:56 +03:00
|
|
|
match: Record<T, string>
|
|
|
|
}
|
|
|
|
).match = this.parse(m[0])
|
2023-06-05 03:30:48 +03:00
|
|
|
|
2021-06-20 01:29:40 +03:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|