From c7d82d41f0a9f85004fe6381ba9710eebc2939f6 Mon Sep 17 00:00:00 2001 From: Alina Sireneva Date: Fri, 6 Oct 2023 01:47:45 +0300 Subject: [PATCH] feat: message groups --- packages/client/scripts/generate-client.js | 45 +-- packages/client/scripts/generate-updates.js | 29 +- packages/client/scripts/update-types.txt | 1 + packages/client/src/client.ts | 105 ++++-- packages/client/src/methods/_options.ts | 64 ++++ packages/client/src/methods/updates.ts | 31 +- packages/client/src/types/messages/message.ts | 9 + packages/client/src/types/updates/index.ts | 1 + packages/dispatcher/src/dispatcher.ts | 66 +++- packages/dispatcher/src/filters/logic.ts | 352 +++++++++++++----- packages/dispatcher/src/filters/state.ts | 2 +- packages/dispatcher/src/filters/types.ts | 2 +- packages/dispatcher/src/handler.ts | 2 + 13 files changed, 504 insertions(+), 205 deletions(-) create mode 100644 packages/client/src/methods/_options.ts diff --git a/packages/client/scripts/generate-client.js b/packages/client/scripts/generate-client.js index 48fe0110..d0a60bd0 100644 --- a/packages/client/scripts/generate-client.js +++ b/packages/client/scripts/generate-client.js @@ -631,50 +631,7 @@ on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this ) output.write('}\n') - output.write( - ` -export interface TelegramClientOptions extends BaseTelegramClientOptions { - /** - * **ADVANCED** - * - * Whether to disable no-dispatch mechanism. - * - * No-dispatch is a mechanism that allows you to call methods - * that return updates and correctly handle them, without - * actually dispatching them to the event handlers. - * - * In other words, the following code will work differently: - * \`\`\`ts - * dp.onNewMessage(console.log) - * console.log(tg.sendText('me', 'hello')) - * \`\`\` - * - if \`disableNoDispatch\` is \`true\`, the sent message will be - * dispatched to the event handler, thus it will be printed twice - * - if \`disableNoDispatch\` is \`false\`, the sent message will not be - * dispatched to the event handler, thus it will onlt be printed once - * - * Disabling it also may improve performance, but it's not guaranteed. - * - * @default false - */ - disableNoDispatch?: boolean - - /** - * Limit of {@link resolvePeerMany} internal async pool. - * - * Higher value means more parallel requests, but also - * higher risk of getting flood-wait errors. - * Most resolves will however likely be a DB cache hit. - * - * Only change this if you know what you're doing. - * - * @default 8 - */ - resolvePeerManyPoolLimit?: number -} -`.trim(), - ) - + output.write('\nexport { TelegramClientOptions }\n') output.write('\nexport class TelegramClient extends BaseTelegramClient {\n') state.fields.forEach(({ code }) => output.write(`protected ${code}\n`)) diff --git a/packages/client/scripts/generate-updates.js b/packages/client/scripts/generate-updates.js index b4469861..85b3af8b 100644 --- a/packages/client/scripts/generate-updates.js +++ b/packages/client/scripts/generate-updates.js @@ -8,16 +8,12 @@ const snakeToCamel = (s) => { }) } -const camelToPascal = (s) => - s[0].toUpperCase() + s.substr(1) +const camelToPascal = (s) => s[0].toUpperCase() + s.substr(1) const camelToSnake = (s) => { - return s.replace( - /(?<=[a-zA-Z0-9])([A-Z0-9]+(?=[A-Z]|$)|[A-Z0-9])/g, - ($1) => { - return '_' + $1.toLowerCase() - }, - ) + return s.replace(/(?<=[a-zA-Z0-9])([A-Z0-9]+(?=[A-Z]|$)|[A-Z0-9])/g, ($1) => { + return '_' + $1.toLowerCase() + }) } function parseUpdateTypes() { @@ -30,15 +26,13 @@ function parseUpdateTypes() { const ret = [] for (const line of lines) { - const m = line.match(/^([a-z_]+)(?:: ([a-zA-Z]+))? = ([a-zA-Z]+)( \+ State)?$/) + const m = line.match(/^([a-z_]+)(?:: ([a-zA-Z]+))? = ([a-zA-Z]+(?:\[\])?)( \+ State)?$/) if (!m) throw new Error(`invalid syntax: ${line}`) ret.push({ typeName: m[1], handlerTypeName: m[2] || camelToPascal(snakeToCamel(m[1])), updateType: m[3], - funcName: m[2] ? - m[2][0].toLowerCase() + m[2].substr(1) : - snakeToCamel(m[1]), + funcName: m[2] ? m[2][0].toLowerCase() + m[2].substr(1) : snakeToCamel(m[1]), state: Boolean(m[4]), }) } @@ -47,9 +41,7 @@ function parseUpdateTypes() { } function replaceSections(filename, sections, dir = __dirname) { - let lines = fs - .readFileSync(path.join(dir, '../src', filename), 'utf-8') - .split('\n') + let lines = fs.readFileSync(path.join(dir, '../src', filename), 'utf-8').split('\n') const findMarker = (marker) => { const idx = lines.findIndex((line) => line.trim() === `// ${marker}`) @@ -84,9 +76,7 @@ async function formatFile(filename, dir = __dirname) { } function toSentence(type, stype = 'inline') { - const name = camelToSnake(type.handlerTypeName) - .toLowerCase() - .replace(/_/g, ' ') + const name = camelToSnake(type.handlerTypeName).toLowerCase().replace(/_/g, ' ') if (stype === 'inline') { return `${name[0].match(/[aeiouy]/i) ? 'an' : 'a'} ${name} handler` @@ -99,7 +89,8 @@ function toSentence(type, stype = 'inline') { function generateParsedUpdate() { replaceSections('types/updates/index.ts', { - codegen: 'export type ParsedUpdate =\n' + + codegen: + 'export type ParsedUpdate =\n' + types.map((typ) => ` | { name: '${typ.typeName}'; data: ${typ.updateType} }\n`).join(''), }) } diff --git a/packages/client/scripts/update-types.txt b/packages/client/scripts/update-types.txt index 6a2b3cb5..53a6d7d9 100644 --- a/packages/client/scripts/update-types.txt +++ b/packages/client/scripts/update-types.txt @@ -1,6 +1,7 @@ # format: type_name[: handler_type_name] = update_type[ + State] new_message = Message + State edit_message = Message + State +message_group = Message[] + State delete_message = DeleteMessageUpdate chat_member: ChatMemberUpdate = ChatMemberUpdate inline_query = InlineQuery diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index d93bdeca..074671ef 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -329,6 +329,66 @@ import { } from './types' import { RpsMeter } from './utils/rps-meter' +// from methods/_options.ts +interface TelegramClientOptions extends BaseTelegramClientOptions { + /** + * **ADVANCED** + * + * Whether to disable no-dispatch mechanism. + * + * No-dispatch is a mechanism that allows you to call methods + * that return updates and correctly handle them, without + * actually dispatching them to the event handlers. + * + * In other words, the following code will work differently: + * ```ts + * dp.onNewMessage(console.log) + * console.log(await tg.sendText('me', 'hello')) + * ``` + * - if `disableNoDispatch` is `true`, the sent message will be + * dispatched to the event handler, thus it will be printed twice + * - if `disableNoDispatch` is `false`, the sent message will not be + * dispatched to the event handler, thus it will onlt be printed once + * + * Disabling it also may improve performance, but it's not guaranteed. + * + * @default false + */ + disableNoDispatch?: boolean + + /** + * Limit of {@link resolvePeerMany} internal async pool. + * + * Higher value means more parallel requests, but also + * higher risk of getting flood-wait errors. + * Most resolves will however likely be a DB cache hit. + * + * Only change this if you know what you're doing. + * + * @default 8 + */ + resolvePeerManyPoolLimit?: number + + /** + * When non-zero, allows the library to automatically handle Telegram + * media groups (e.g. albums) in {@link MessageGroup} updates + * in a given time interval (in ms). + * + * **Note**: this does not catch messages that happen to be consecutive, + * only messages belonging to the same "media group". + * + * This will cause up to `messageGroupingInterval` delay + * in handling media group messages. + * + * This option only applies to `new_message` updates, + * and the updates being grouped **will not** be dispatched on their own. + * + * Recommended value is 250 ms. + * + * @default 0 (disabled) + */ + messageGroupingInterval?: number +} // from methods/updates.ts interface PendingUpdateContainer { upd: tl.TypeUpdates @@ -5482,45 +5542,9 @@ export interface TelegramClient extends BaseTelegramClient { bio?: string }): Promise } -export interface TelegramClientOptions extends BaseTelegramClientOptions { - /** - * **ADVANCED** - * - * Whether to disable no-dispatch mechanism. - * - * No-dispatch is a mechanism that allows you to call methods - * that return updates and correctly handle them, without - * actually dispatching them to the event handlers. - * - * In other words, the following code will work differently: - * ```ts - * dp.onNewMessage(console.log) - * console.log(tg.sendText('me', 'hello')) - * ``` - * - if `disableNoDispatch` is `true`, the sent message will be - * dispatched to the event handler, thus it will be printed twice - * - if `disableNoDispatch` is `false`, the sent message will not be - * dispatched to the event handler, thus it will onlt be printed once - * - * Disabling it also may improve performance, but it's not guaranteed. - * - * @default false - */ - disableNoDispatch?: boolean - /** - * Limit of {@link resolvePeerMany} internal async pool. - * - * Higher value means more parallel requests, but also - * higher risk of getting flood-wait errors. - * Most resolves will however likely be a DB cache hit. - * - * Only change this if you know what you're doing. - * - * @default 8 - */ - resolvePeerManyPoolLimit?: number -} +export { TelegramClientOptions } + export class TelegramClient extends BaseTelegramClient { protected _userId: number | null protected _isBot: boolean @@ -5544,6 +5568,8 @@ export class TelegramClient extends BaseTelegramClient { protected _updLock: AsyncLock protected _rpsIncoming?: RpsMeter protected _rpsProcessing?: RpsMeter + protected _messageGroupingInterval: number + protected _messageGroupingPending: Map protected _pts?: number protected _qts?: number protected _date?: number @@ -5583,6 +5609,9 @@ export class TelegramClient extends BaseTelegramClient { this._noDispatchPts = new Map() this._noDispatchQts = new Set() + this._messageGroupingInterval = opts.messageGroupingInterval ?? 0 + this._messageGroupingPending = new Map() + this._updLock = new AsyncLock() // we dont need to initialize state fields since // they are always loaded either from the server, or from storage. diff --git a/packages/client/src/methods/_options.ts b/packages/client/src/methods/_options.ts new file mode 100644 index 00000000..d7f0da4d --- /dev/null +++ b/packages/client/src/methods/_options.ts @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { BaseTelegramClientOptions } from '@mtcute/core' + +// @copy +interface TelegramClientOptions extends BaseTelegramClientOptions { + /** + * **ADVANCED** + * + * Whether to disable no-dispatch mechanism. + * + * No-dispatch is a mechanism that allows you to call methods + * that return updates and correctly handle them, without + * actually dispatching them to the event handlers. + * + * In other words, the following code will work differently: + * ```ts + * dp.onNewMessage(console.log) + * console.log(await tg.sendText('me', 'hello')) + * ``` + * - if `disableNoDispatch` is `true`, the sent message will be + * dispatched to the event handler, thus it will be printed twice + * - if `disableNoDispatch` is `false`, the sent message will not be + * dispatched to the event handler, thus it will onlt be printed once + * + * Disabling it also may improve performance, but it's not guaranteed. + * + * @default false + */ + disableNoDispatch?: boolean + + /** + * Limit of {@link resolvePeerMany} internal async pool. + * + * Higher value means more parallel requests, but also + * higher risk of getting flood-wait errors. + * Most resolves will however likely be a DB cache hit. + * + * Only change this if you know what you're doing. + * + * @default 8 + */ + resolvePeerManyPoolLimit?: number + + /** + * When non-zero, allows the library to automatically handle Telegram + * media groups (e.g. albums) in {@link MessageGroup} updates + * in a given time interval (in ms). + * + * **Note**: this does not catch messages that happen to be consecutive, + * only messages belonging to the same "media group". + * + * This will cause up to `messageGroupingInterval` delay + * in handling media group messages. + * + * This option only applies to `new_message` updates, + * and the updates being grouped **will not** be dispatched on their own. + * + * Recommended value is 250 ms. + * + * @default 0 (disabled) + */ + messageGroupingInterval?: number +} diff --git a/packages/client/src/methods/updates.ts b/packages/client/src/methods/updates.ts index 41ff7de9..3ddef0e0 100644 --- a/packages/client/src/methods/updates.ts +++ b/packages/client/src/methods/updates.ts @@ -13,7 +13,7 @@ import { } from '@mtcute/core/utils' import { TelegramClient, TelegramClientOptions } from '../client' -import { PeersIndex } from '../types' +import { Message, PeersIndex } from '../types' import { _parseUpdate } from '../types/updates/parse-update' import { extractChannelIdFromUpdate } from '../utils/misc-utils' import { normalizeToInputChannel } from '../utils/peer-utils' @@ -65,6 +65,9 @@ interface UpdatesState { _rpsIncoming?: RpsMeter _rpsProcessing?: RpsMeter + _messageGroupingInterval: number + _messageGroupingPending: Map + // accessing storage every time might be expensive, // so store everything here, and load & save // every time session is loaded & saved. @@ -109,6 +112,9 @@ function _initializeUpdates(this: TelegramClient, opts: TelegramClientOptions) { this._noDispatchPts = new Map() this._noDispatchQts = new Set() + this._messageGroupingInterval = opts.messageGroupingInterval ?? 0 + this._messageGroupingPending = new Map() + this._updLock = new AsyncLock() // we dont need to initialize state fields since // they are always loaded either from the server, or from storage. @@ -375,6 +381,29 @@ export function _dispatchUpdate(this: TelegramClient, update: tl.TypeUpdate, pee const parsed = _parseUpdate(this, update, peers) if (parsed) { + if (this._messageGroupingInterval && parsed.name === 'new_message') { + const group = parsed.data.groupedIdUnique + + if (group) { + const pendingGroup = this._messageGroupingPending.get(group) + + if (pendingGroup) { + pendingGroup[0].push(parsed.data) + } else { + const messages = [parsed.data] + const timeout = setTimeout(() => { + this._messageGroupingPending.delete(group) + this.emit('update', { name: 'message_group', data: messages }) + this.emit('message_group', messages) + }, this._messageGroupingInterval) + + this._messageGroupingPending.set(group, [messages, timeout]) + } + + return + } + } + this.emit('update', parsed) this.emit(parsed.name, parsed.data) } diff --git a/packages/client/src/types/messages/message.ts b/packages/client/src/types/messages/message.ts index fc9f70c1..91ae9c93 100644 --- a/packages/client/src/types/messages/message.ts +++ b/packages/client/src/types/messages/message.ts @@ -161,6 +161,15 @@ export class Message { return this.raw._ === 'message' ? this.raw.groupedId ?? null : null } + /** + * Same as {@link groupedId}, but is globally unique across chats. + */ + get groupedIdUnique(): string | null { + if (!(this.raw._ === 'message' && this.raw.groupedId !== undefined)) return null + + return `${this.raw.groupedId.low}|${this.raw.groupedId.high}|${getMarkedPeerId(this.raw.peerId)}` + } + private _sender?: User | Chat /** diff --git a/packages/client/src/types/updates/index.ts b/packages/client/src/types/updates/index.ts index e466d9d1..ad417aed 100644 --- a/packages/client/src/types/updates/index.ts +++ b/packages/client/src/types/updates/index.ts @@ -36,6 +36,7 @@ export { export type ParsedUpdate = | { name: 'new_message'; data: Message } | { name: 'edit_message'; data: Message } + | { name: 'message_group'; data: Message[] } | { name: 'delete_message'; data: DeleteMessageUpdate } | { name: 'chat_member'; data: ChatMemberUpdate } | { name: 'inline_query'; data: InlineQuery } diff --git a/packages/dispatcher/src/dispatcher.ts b/packages/dispatcher/src/dispatcher.ts index 92d1f81d..62195527 100644 --- a/packages/dispatcher/src/dispatcher.ts +++ b/packages/dispatcher/src/dispatcher.ts @@ -41,6 +41,7 @@ import { EditMessageHandler, HistoryReadHandler, InlineQueryHandler, + MessageGroupHandler, NewMessageHandler, PollUpdateHandler, PollVoteHandler, @@ -109,7 +110,10 @@ export class Dispatcher { * Create a new dispatcher and bind it to client and optionally * FSM storage */ - constructor(client: TelegramClient, ...args: State extends never ? [] : [IStateStorage, StateKeyDelegate?]) + constructor( + client: TelegramClient, + ...args: (() => State) extends () => never ? [] : [IStateStorage, StateKeyDelegate?] + ) constructor( client?: TelegramClient | IStateStorage | StateKeyDelegate, storage?: IStateStorage | StateKeyDelegate, @@ -290,11 +294,16 @@ export class Dispatcher { if ( this._storage && this._scenes && - (update.name === 'new_message' || update.name === 'edit_message' || update.name === 'callback_query') + (update.name === 'new_message' || + update.name === 'edit_message' || + update.name === 'callback_query' || + update.name === 'message_group') ) { // no need to fetch scene if there are no registered scenes - const key = await this._stateKeyDelegate!(update.data) + const key = await this._stateKeyDelegate!( + update.name === 'message_group' ? update.data[0] : update.data, + ) if (key) { parsedScene = await this._storage.getCurrentScene(key) @@ -1034,6 +1043,57 @@ export class Dispatcher { this._addKnownHandler('edit_message', filter, handler, group) } + /** + * Register a message group handler without any filters + * + * @param handler Message group handler + * @param group Handler group index + */ + onMessageGroup( + handler: MessageGroupHandler< + Message[], + State extends never ? never : UpdateState + >['callback'], + group?: number, + ): void + + /** + * Register a message group handler with a filter + * + * @param filter Update filter + * @param handler Message group handler + * @param group Handler group index + */ + onMessageGroup( + filter: UpdateFilter, + handler: MessageGroupHandler< + filters.Modify, + State extends never ? never : UpdateState + >['callback'], + group?: number, + ): void + + /** + * Register a message group handler with a filter + * + * @param filter Update filter + * @param handler Message group handler + * @param group Handler group index + */ + onMessageGroup( + filter: UpdateFilter, + handler: MessageGroupHandler< + filters.Modify, + State extends never ? never : UpdateState + >['callback'], + group?: number, + ): void + + /** @internal */ + onMessageGroup(filter: any, handler?: any, group?: number): void { + this._addKnownHandler('message_group', filter, handler, group) + } + /** * Register a delete message handler without any filters * diff --git a/packages/dispatcher/src/filters/logic.ts b/packages/dispatcher/src/filters/logic.ts index e6af9c1d..00a8736a 100644 --- a/packages/dispatcher/src/filters/logic.ts +++ b/packages/dispatcher/src/filters/logic.ts @@ -3,8 +3,7 @@ import { MaybeAsync } from '@mtcute/core' -import { UpdateState } from '../state' -import { ExtractBaseMany, ExtractMod, ExtractState, Invert, UnionToIntersection, UpdateFilter } from './types' +import { ExtractBaseMany, ExtractMod, Invert, UnionToIntersection, UpdateFilter } from './types' /** * Filter that matches any update @@ -35,9 +34,90 @@ export function not( } } +// i couldn't come up with proper types for these 😭 +// if you know how to do this better - PRs are welcome! + +export function and( + fn1: UpdateFilter, + fn2: UpdateFilter, +): UpdateFilter +export function and( + fn1: UpdateFilter, + fn2: UpdateFilter, + fn3: UpdateFilter, +): UpdateFilter +export function and( + fn1: UpdateFilter, + fn2: UpdateFilter, + fn3: UpdateFilter, + fn4: UpdateFilter, +): UpdateFilter +export function and< + Base1, + Mod1, + State1, + Base2, + Mod2, + State2, + Base3, + Mod3, + State3, + Base4, + Mod4, + State4, + Base5, + Mod5, + State5, +>( + fn1: UpdateFilter, + fn2: UpdateFilter, + fn3: UpdateFilter, + fn4: UpdateFilter, + fn5: UpdateFilter, +): UpdateFilter< + Base1 & Base2 & Base3 & Base4 & Base5, + Mod1 & Mod2 & Mod3 & Mod4 & Mod5, + State1 | State2 | State3 | State4 | State5 +> +export function and< + Base1, + Mod1, + State1, + Base2, + Mod2, + State2, + Base3, + Mod3, + State3, + Base4, + Mod4, + State4, + Base5, + Mod5, + State5, + Base6, + Mod6, + State6, +>( + fn1: UpdateFilter, + fn2: UpdateFilter, + fn3: UpdateFilter, + fn4: UpdateFilter, + fn5: UpdateFilter, + fn6: UpdateFilter, +): UpdateFilter< + Base1 & Base2 & Base3 & Base4 & Base5 & Base6, + Mod1 & Mod2 & Mod3 & Mod4 & Mod5 & Mod6, + State1 | State2 | State3 | State4 | State5 | State6 +> +export function and[]>( + ...fns: Filters +): UpdateFilter, UnionToIntersection>> + /** - * Combine two filters by applying an AND logical operation: - * `and(fn1, fn2) = fn1 AND fn2` + * Combine multiple filters by applying an AND logical + * operation between every one of them: + * `and(fn1, fn2, ..., fnN) = fn1 AND fn2 AND ... AND fnN` * * > **Note**: This also combines type modifications, i.e. * > if the 1st has modification `{ field1: string }` @@ -45,92 +125,14 @@ export function not( * > then the combined filter will have * > combined modification `{ field1: string, field2: number }` * - * @param fn1 First filter - * @param fn2 Second filter - */ -export function and( - fn1: UpdateFilter, - fn2: UpdateFilter, -): UpdateFilter { - return (upd, state) => { - const res1 = fn1(upd, state as UpdateState) - - if (typeof res1 === 'boolean') { - if (!res1) return false - - return fn2(upd, state as UpdateState) - } - - return res1.then((r1) => { - if (!r1) return false - - return fn2(upd, state as UpdateState) - }) - } -} - -/** - * Combine two filters by applying an OR logical operation: - * `or(fn1, fn2) = fn1 OR fn2` - * - * > **Note**: This also combines type modifications in a union, i.e. - * > if the 1st has modification `{ field1: string }` - * > and the 2nd has modification `{ field2: number }`, - * > then the combined filter will have - * > modification `{ field1: string } | { field2: number }`. - * > - * > It is up to the compiler to handle `if`s inside - * > the handler function code, but this works with other - * > logical functions as expected. - * - * @param fn1 First filter - * @param fn2 Second filter - */ -export function or( - fn1: UpdateFilter, - fn2: UpdateFilter, -): UpdateFilter { - return (upd, state) => { - const res1 = fn1(upd, state as UpdateState) - - if (typeof res1 === 'boolean') { - if (res1) return true - - return fn2(upd, state as UpdateState) - } - - return res1.then((r1) => { - if (r1) return true - - return fn2(upd, state as UpdateState) - }) - } -} - -// im pretty sure it can be done simpler (return types of some and every), -// so if you know how - PRs are welcome! - -/** - * Combine multiple filters by applying an AND logical - * operation between every one of them: - * `every(fn1, fn2, ..., fnN) = fn1 AND fn2 AND ... AND fnN` - * - * > **Note**: This also combines type modification in a way - * > similar to {@link and}. - * > - * > This method is less efficient than {@link and} - * - * > **Note**: This method *currently* does not propagate state - * > type. This might be fixed in the future, but for now either - * > use {@link and} or add type manually. + * > **Note**: Due to TypeScript limitations (or more likely my lack of brain cells), + * > state type is only correctly inferred for up to 6 filters. + * > If you need more, either provide type explicitly (e.g. `filters.state(...)`), + * > or combine multiple `and` calls. * * @param fns Filters to combine */ -export function every[]>( - ...fns: Filters -): UpdateFilter, UnionToIntersection>> { - if (fns.length === 2) return and(fns[0], fns[1]) - +export function and(...fns: UpdateFilter[]): UpdateFilter { return (upd, state) => { let i = 0 const max = fns.length @@ -157,25 +159,103 @@ export function every[]>( } } +export function or( + fn1: UpdateFilter, + fn2: UpdateFilter, +): UpdateFilter + +export function or( + fn1: UpdateFilter, + fn2: UpdateFilter, + fn3: UpdateFilter, +): UpdateFilter + +export function or( + fn1: UpdateFilter, + fn2: UpdateFilter, + fn3: UpdateFilter, + fn4: UpdateFilter, +): UpdateFilter + +export function or< + Base1, + Mod1, + State1, + Base2, + Mod2, + State2, + Base3, + Mod3, + State3, + Base4, + Mod4, + State4, + Base5, + Mod5, + State5, +>( + fn1: UpdateFilter, + fn2: UpdateFilter, + fn3: UpdateFilter, + fn4: UpdateFilter, + fn5: UpdateFilter, +): UpdateFilter< + Base1 & Base2 & Base3 & Base4 & Base5, + Mod1 | Mod2 | Mod3 | Mod4 | Mod5, + State1 | State2 | State3 | State4 | State5 +> + +export function or< + Base1, + Mod1, + State1, + Base2, + Mod2, + State2, + Base3, + Mod3, + State3, + Base4, + Mod4, + State4, + Base5, + Mod5, + State5, + Base6, + Mod6, + State6, +>( + fn1: UpdateFilter, + fn2: UpdateFilter, + fn3: UpdateFilter, + fn4: UpdateFilter, + fn5: UpdateFilter, + fn6: UpdateFilter, +): UpdateFilter< + Base1 & Base2 & Base3 & Base4 & Base5 & Base6, + Mod1 | Mod2 | Mod3 | Mod4 | Mod5 | Mod6, + State1 | State2 | State3 | State4 | State5 | State6 +> + /** * Combine multiple filters by applying an OR logical * operation between every one of them: - * `every(fn1, fn2, ..., fnN) = fn1 OR fn2 OR ... OR fnN` + * `or(fn1, fn2, ..., fnN) = fn1 OR fn2 OR ... OR fnN` * - * > **Note**: This also combines type modification in a way - * > similar to {@link or}. - * > - * > This method is less efficient than {@link or} + * > **Note**: This also combines type modifications in a union, i.e. + * > if the 1st has modification `{ field1: string }` + * > and the 2nd has modification `{ field2: number }`, + * > then the combined filter will have + * > modification `{ field1: string } | { field2: number }`. * - * > **Note**: This method *currently* does not propagate state - * > type. This might be fixed in the future, but for now either - * > use {@link or} or add type manually. + * > **Note**: Due to TypeScript limitations (or more likely my lack of brain cells), + * > state type is only correctly inferred for up to 6 filters. + * > If you need more, either provide type explicitly (e.g. `filters.state(...)`), + * > or combine multiple `and` calls. * * @param fns Filters to combine */ -export function some[]>( - ...fns: Filters -): UpdateFilter, ExtractMod, ExtractState> { +export function or(...fns: UpdateFilter[]): UpdateFilter { if (fns.length === 2) return or(fns[0], fns[1]) return (upd, state) => { @@ -203,3 +283,79 @@ export function some[]>( return next() } } + +/** + * For updates that contain an array of updates (e.g. `message_group`), + * apply a filter to every element of the array. + * + * Filter will match if **all** elements match. + * + * > **Note**: This also applies type modification to every element of the array. + * + * @param filter + * @returns + */ +export function every(filter: UpdateFilter): UpdateFilter { + return (upds, state) => { + let i = 0 + const max = upds.length + + const next = (): MaybeAsync => { + if (i === max) return true + + const res = filter(upds[i++], state) + + if (typeof res === 'boolean') { + if (!res) return false + + return next() + } + + return res.then((r: boolean) => { + if (!r) return false + + return next() + }) + } + + return next() + } +} + +/** + * For updates that contain an array of updates (e.g. `message_group`), + * apply a filter to every element of the array. + * + * Filter will match if **all** elements match. + * + * > **Note**: This *does not* apply type modification to any element of the array + * + * @param filter + * @returns + */ +export function some(filter: UpdateFilter): UpdateFilter { + return (upds, state) => { + let i = 0 + const max = upds.length + + const next = (): MaybeAsync => { + if (i === max) return false + + const res = filter(upds[i++], state) + + if (typeof res === 'boolean') { + if (res) return true + + return next() + } + + return res.then((r: boolean) => { + if (r) return true + + return next() + }) + } + + return next() + } +} diff --git a/packages/dispatcher/src/filters/state.ts b/packages/dispatcher/src/filters/state.ts index c1131a3f..32aefd44 100644 --- a/packages/dispatcher/src/filters/state.ts +++ b/packages/dispatcher/src/filters/state.ts @@ -23,7 +23,7 @@ export const stateEmpty: UpdateFilter = async (upd, state) => { export const state = ( predicate: (state: T) => MaybeAsync, // eslint-disable-next-line @typescript-eslint/ban-types -): UpdateFilter => { +): UpdateFilter => { return async (upd, state) => { if (!state) return false const data = await state.get() diff --git a/packages/dispatcher/src/filters/types.ts b/packages/dispatcher/src/filters/types.ts index bef1bfbe..b47966c5 100644 --- a/packages/dispatcher/src/filters/types.ts +++ b/packages/dispatcher/src/filters/types.ts @@ -79,7 +79,7 @@ export type UpdateFilter = ( state?: UpdateState, ) => MaybeAsync -export type Modify = Omit & Mod +export type Modify = Base extends (infer T)[] ? Modify[] : Omit & Mod export type Invert = { [P in keyof Mod & keyof Base]: Exclude } diff --git a/packages/dispatcher/src/handler.ts b/packages/dispatcher/src/handler.ts index 8d1f9d82..92dfef20 100644 --- a/packages/dispatcher/src/handler.ts +++ b/packages/dispatcher/src/handler.ts @@ -50,6 +50,7 @@ export type RawUpdateHandler = BaseUpdateHandler< // begin-codegen export type NewMessageHandler = ParsedUpdateHandler<'new_message', T, S> export type EditMessageHandler = ParsedUpdateHandler<'edit_message', T, S> +export type MessageGroupHandler = ParsedUpdateHandler<'message_group', T, S> export type DeleteMessageHandler = ParsedUpdateHandler<'delete_message', T> export type ChatMemberUpdateHandler = ParsedUpdateHandler<'chat_member', T> export type InlineQueryHandler = ParsedUpdateHandler<'inline_query', T> @@ -71,6 +72,7 @@ export type UpdateHandler = | RawUpdateHandler | NewMessageHandler | EditMessageHandler + | MessageGroupHandler | DeleteMessageHandler | ChatMemberUpdateHandler | InlineQueryHandler