From 6e8351ac0167940b5a408275ee7c521771510420 Mon Sep 17 00:00:00 2001 From: Alina Sireneva Date: Thu, 21 Sep 2023 14:48:08 +0300 Subject: [PATCH] refactor: extracted dispatcher filters into multiple files --- .../src/types/messages/message-media.ts | 1 + packages/dispatcher/src/filters.ts | 1342 ----------------- packages/dispatcher/src/filters/bots.ts | 150 ++ packages/dispatcher/src/filters/bundle.ts | 9 + packages/dispatcher/src/filters/chat.ts | 86 ++ packages/dispatcher/src/filters/index.ts | 3 + packages/dispatcher/src/filters/logic.ts | 219 +++ packages/dispatcher/src/filters/message.ts | 227 +++ packages/dispatcher/src/filters/state.ts | 34 + packages/dispatcher/src/filters/text.ts | 182 +++ packages/dispatcher/src/filters/types.ts | 117 ++ packages/dispatcher/src/filters/updates.ts | 85 ++ packages/dispatcher/src/filters/user.ts | 196 +++ 13 files changed, 1309 insertions(+), 1342 deletions(-) delete mode 100644 packages/dispatcher/src/filters.ts create mode 100644 packages/dispatcher/src/filters/bots.ts create mode 100644 packages/dispatcher/src/filters/bundle.ts create mode 100644 packages/dispatcher/src/filters/chat.ts create mode 100644 packages/dispatcher/src/filters/index.ts create mode 100644 packages/dispatcher/src/filters/logic.ts create mode 100644 packages/dispatcher/src/filters/message.ts create mode 100644 packages/dispatcher/src/filters/state.ts create mode 100644 packages/dispatcher/src/filters/text.ts create mode 100644 packages/dispatcher/src/filters/types.ts create mode 100644 packages/dispatcher/src/filters/updates.ts create mode 100644 packages/dispatcher/src/filters/user.ts diff --git a/packages/client/src/types/messages/message-media.ts b/packages/client/src/types/messages/message-media.ts index 9baf9df4..8254b525 100644 --- a/packages/client/src/types/messages/message-media.ts +++ b/packages/client/src/types/messages/message-media.ts @@ -40,6 +40,7 @@ export type MessageMedia = | Poll | Invoice | null +export type MessageMediaType = Exclude['type'] // todo: successful_payment, connected_website diff --git a/packages/dispatcher/src/filters.ts b/packages/dispatcher/src/filters.ts deleted file mode 100644 index 3c87c79c..00000000 --- a/packages/dispatcher/src/filters.ts +++ /dev/null @@ -1,1342 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// ^^ will be looked into in MTQ-29 -import { - Audio, - BotChatJoinRequestUpdate, - CallbackQuery, - Chat, - ChatMemberUpdate, - ChatMemberUpdateType, - ChatType, - ChosenInlineResult, - Contact, - Dice, - Document, - Game, - InlineQuery, - Invoice, - LiveLocation, - Location, - MaybeAsync, - Message, - MessageAction, - Photo, - Poll, - PollVoteUpdate, - RawDocument, - RawLocation, - Sticker, - StickerSourceType, - StickerType, - User, - UserStatus, - UserStatusUpdate, - UserTypingUpdate, - Venue, - Video, - Voice, - WebPage, -} from '@mtcute/client' -import { MaybeArray } from '@mtcute/core' - -import { UpdateState } from './state' - -function extractText( - obj: Message | InlineQuery | ChosenInlineResult | CallbackQuery, -): string | null { - if (obj.constructor === Message) { - return obj.text - } else if (obj.constructor === InlineQuery) { - return obj.query - } else if (obj.constructor === ChosenInlineResult) { - return obj.id - } else if (obj.constructor === CallbackQuery) { - if (obj.raw.data) return obj.dataStr - } - - return null -} - -/** - * Type describing a primitive filter, which is a function taking some `Base` - * and a {@link TelegramClient}, checking it against some condition - * and returning a boolean. - * - * If `true` is returned, the filter is considered - * to be matched, and the appropriate update handler function is called, - * otherwise next registered handler is checked. - * - * Additionally, filter might contain a type modification - * to `Base` for better code insights. If it is present, - * it is used to overwrite types (!) of some of the `Base` fields - * to given (note that this is entirely compile-time! object is not modified) - * - * For parametrized filters (like {@link filters.regex}), - * type modification can also be used to add additional fields - * (in case of `regex`, its match array is added to `.match`) - * - * Example without type mod: - * ```typescript - * - * const hasPhoto: UpdateFilter = msg => msg.media?.type === 'photo' - * - * // ..later.. - * tg.onNewMessage(hasPhoto, async (msg) => { - * // `hasPhoto` filter matched, so we can safely assume - * // that `msg.media` is a Photo. - * // - * // but it is very redundant, verbose and error-rome, - * // wonder if we could make typescript do this automagically and safely... - * await (msg.media as Photo).downloadToFile(`${msg.id}.jpg`) - * }) - * ``` - * - * Example with type mod: - * ```typescript - * - * const hasPhoto: UpdateFilter = msg => msg.media?.type === 'photo' - * - * // ..later.. - * tg.onNewMessage(hasPhoto, async (msg) => { - * // since `hasPhoto` filter matched, - * // we have applied the modification to `msg`, - * // and `msg.media` now has type `Photo` - * // - * // no more redundancy and type casts! - * await msg.media.downloadToFile(`${msg.id}.jpg`) - * }) - * ``` - * - * > **Note**: Type modification can contain anything, even totally unrelated types - * > and it is *your* task to keep track that everything is correct. - * > - * > Bad example: - * > ```typescript - * > // we check for `Photo`, but type contains `Audio`. this will be a problem! - * > const hasPhoto: UpdateFilter = msg => msg.media?.type === 'photo' - * > - * > // ..later.. - * > tg.onNewMessage(hasPhoto, async (msg) => { - * > // oops! `msg.media` is `Audio` and does not have `.width`! - * > console.log(msg.media.width) - * > }) - * > ``` - * - * > **Warning!** Do not use the generics provided in functions - * > like `and`, `or`, etc. Those are meant to be inferred by the compiler! - */ -// we need the second parameter because it carries meta information -// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-types -export type UpdateFilter = ( - update: Base, - state?: UpdateState -) => MaybeAsync - -// eslint-disable-next-line @typescript-eslint/no-namespace -export namespace filters { - export type Modify = Omit & Mod - export type Invert = { - [P in keyof Mod & keyof Base]: Exclude - } - - export type UnionToIntersection = ( - U extends any ? (k: U) => void : never - ) extends (k: infer I) => void - ? I - : never - - type ExtractBase = Filter extends UpdateFilter - ? I - : never - - type ExtractMod = Filter extends UpdateFilter - ? I - : never - - type ExtractState = Filter extends UpdateFilter - ? I - : never - - type TupleKeys = Exclude - type WrapBase = { - [K in TupleKeys]: { base: ExtractBase } - } - type Values = T[keyof T] - type UnwrapBase = T extends { base: any } ? T['base'] : never - type ExtractBaseMany = UnwrapBase< - UnionToIntersection>> - > - - /** - * Invert a filter by applying a NOT logical operation: - * `not(fn) = NOT fn` - * - * > **Note**: This also inverts type modification, i.e. - * > if the base is `{ field: string | number | null }` - * > and the modification is `{ field: string }`, - * > then the negated filter will have - * > inverted modification `{ field: number | null }` - * - * @param fn Filter to negate - */ - export function not( - fn: UpdateFilter, - ): UpdateFilter, State> { - return (upd, state) => { - const res = fn(upd, state) - - if (typeof res === 'boolean') return !res - - return res.then((r) => !r) - } - } - - /** - * Combine two filters by applying an AND logical operation: - * `and(fn1, fn2) = fn1 AND fn2` - * - * > **Note**: This also combines type modifications, i.e. - * > if the 1st has modification `{ field1: string }` - * > and the 2nd has modification `{ field2: number }`, - * > 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 all and any), - // 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. - * - * @param fns Filters to combine - */ - export function every[]>( - ...fns: Filters - ): UpdateFilter< - ExtractBaseMany, - UnionToIntersection> - > { - if (fns.length === 2) return and(fns[0], fns[1]) - - return (upd, state) => { - let i = 0 - const max = fns.length - - const next = (): MaybeAsync => { - if (i === max) return true - - const res = fns[i++](upd, state) - - if (typeof res === 'boolean') { - if (!res) return false - - return next() - } - - return res.then((r: boolean) => { - if (!r) return false - - return next() - }) - } - - return next() - } - } - - /** - * Combine multiple filters by applying an OR logical - * operation between every one of them: - * `every(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 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. - * - * @param fns Filters to combine - */ - export function some[]>( - ...fns: Filters - ): UpdateFilter< - ExtractBaseMany, - ExtractMod, - ExtractState - > { - if (fns.length === 2) return or(fns[0], fns[1]) - - return (upd, state) => { - let i = 0 - const max = fns.length - - const next = (): MaybeAsync => { - if (i === max) return false - - const res = fns[i++](upd, state) - - if (typeof res === 'boolean') { - if (res) return true - - return next() - } - - return res.then((r: boolean) => { - if (r) return true - - return next() - }) - } - - return next() - } - } - - /** - * Filter that matches any update - */ - export const any: UpdateFilter = () => true - - /** - * Filter messages generated by yourself (including Saved Messages) - */ - export const me: UpdateFilter = (msg) => - (msg.sender.constructor === User && msg.sender.isSelf) || msg.isOutgoing - - /** - * Filter messages sent by bots - */ - export const bot: UpdateFilter = (msg) => - msg.sender.constructor === User && msg.sender.isBot - - /** - * Filter messages by chat type - */ - export const chat = - ( - type: T, - ): UpdateFilter< - Message, - { - chat: Modify - sender: T extends 'private' | 'bot' | 'group' - ? User - : User | Chat - } - > => - (msg) => - msg.chat.chatType === type - - /** - * Filter updates by chat ID(s) or username(s) - */ - export const chatId = ( - id: MaybeArray, - ): UpdateFilter => { - if (Array.isArray(id)) { - const index: Record = {} - let matchSelf = false - id.forEach((id) => { - if (id === 'me' || id === 'self') { - matchSelf = true - } else { - index[id] = true - } - }) - - return (upd) => { - if (upd.constructor === PollVoteUpdate) { - const peer = upd.peer - - return peer.type === 'chat' && peer.id in index - } - - const chat = (upd as Exclude).chat - - return ( - (matchSelf && chat.isSelf) || - chat.id in index || - chat.username! in index - ) - } - } - - if (id === 'me' || id === 'self') { - return (upd) => { - if (upd.constructor === PollVoteUpdate) { - return upd.peer.type === 'chat' && upd.peer.isSelf - } - - return (upd as Exclude).chat.isSelf - } - } - - if (typeof id === 'string') { - return (upd) => { - if (upd.constructor === PollVoteUpdate) { - return upd.peer.type === 'chat' && upd.peer.username === id - } - - return ( - (upd as Exclude).chat - .username === id - ) - } - } - - return (upd) => { - if (upd.constructor === PollVoteUpdate) { - return upd.peer.type === 'chat' && upd.peer.id === id - } - - return (upd as Exclude).chat.id === id - } - } - - /** - * Filter updates by user ID(s) or username(s) - * - * Usernames are not supported for UserStatusUpdate - * and UserTypingUpdate. - * - * - * For chat member updates, uses `user.id` - */ - export const userId = ( - id: MaybeArray, - ): UpdateFilter< - | Message - | UserStatusUpdate - | UserTypingUpdate - | InlineQuery - | ChatMemberUpdate - | ChosenInlineResult - | CallbackQuery - | PollVoteUpdate - | BotChatJoinRequestUpdate - > => { - if (Array.isArray(id)) { - const index: Record = {} - let matchSelf = false - id.forEach((id) => { - if (id === 'me' || id === 'self') { - matchSelf = true - } else { - index[id] = true - } - }) - - return (upd) => { - const ctor = upd.constructor - - if (ctor === Message) { - const sender = (upd as Message).sender - - return ( - (matchSelf && sender.isSelf) || - sender.id in index || - sender.username! in index - ) - } else if ( - ctor === UserStatusUpdate || - ctor === UserTypingUpdate - ) { - const id = (upd as UserStatusUpdate | UserTypingUpdate) - .userId - - return ( - // eslint-disable-next-line dot-notation - (matchSelf && id === upd.client['_userId']) || - id in index - ) - } else if (ctor === PollVoteUpdate) { - const peer = (upd as PollVoteUpdate).peer - if (peer.type !== 'user') return false - - return ( - (matchSelf && peer.isSelf) || - peer.id in index || - peer.username! in index - ) - } - - const user = ( - upd as Exclude< - typeof upd, - | Message - | UserStatusUpdate - | UserTypingUpdate - | PollVoteUpdate - > - ).user - - return ( - (matchSelf && user.isSelf) || - user.id in index || - user.username! in index - ) - } - } - - if (id === 'me' || id === 'self') { - return (upd) => { - const ctor = upd.constructor - - if (ctor === Message) { - return (upd as Message).sender.isSelf - } else if ( - ctor === UserStatusUpdate || - ctor === UserTypingUpdate - ) { - return ( - (upd as UserStatusUpdate | UserTypingUpdate).userId === - // eslint-disable-next-line dot-notation - upd.client['_userId'] - ) - } else if (ctor === PollVoteUpdate) { - const peer = (upd as PollVoteUpdate).peer - if (peer.type !== 'user') return false - - return peer.isSelf - } - - return ( - upd as Exclude< - typeof upd, - | Message - | UserStatusUpdate - | UserTypingUpdate - | PollVoteUpdate - > - ).user.isSelf - } - } - - if (typeof id === 'string') { - return (upd) => { - const ctor = upd.constructor - - if (ctor === Message) { - return (upd as Message).sender.username === id - } else if ( - ctor === UserStatusUpdate || - ctor === UserTypingUpdate - ) { - // username is not available - return false - } else if (ctor === PollVoteUpdate) { - const peer = (upd as PollVoteUpdate).peer - if (peer.type !== 'user') return false - - return peer.username === id - } - - return ( - ( - upd as Exclude< - typeof upd, - | Message - | UserStatusUpdate - | UserTypingUpdate - | PollVoteUpdate - > - ).user.username === id - ) - } - } - - return (upd) => { - const ctor = upd.constructor - - if (ctor === Message) { - return (upd as Message).sender.id === id - } else if (ctor === UserStatusUpdate || ctor === UserTypingUpdate) { - return ( - (upd as UserStatusUpdate | UserTypingUpdate).userId === id - ) - } else if (ctor === PollVoteUpdate) { - const peer = (upd as PollVoteUpdate).peer - if (peer.type !== 'user') return false - - return peer.id === id - } - - return ( - ( - upd as Exclude< - typeof upd, - | Message - | UserStatusUpdate - | UserTypingUpdate - | PollVoteUpdate - > - ).user.id === id - ) - } - } - - /** - * Filter incoming messages. - * - * Messages sent to yourself (i.e. Saved Messages) are also "incoming" - */ - export const incoming: UpdateFilter = ( - msg, - ) => !msg.isOutgoing - - /** - * Filter outgoing messages. - * - * Messages sent to yourself (i.e. Saved Messages) are **not** "outgoing" - */ - export const outgoing: UpdateFilter = ( - msg, - ) => msg.isOutgoing - - /** - * Filter messages that are replies to some other message - */ - export const reply: UpdateFilter = ( - msg, - ) => msg.replyToMessageId !== null - - /** - * Filter messages containing some media - */ - export const media: UpdateFilter< - Message, - { media: Exclude } - > = (msg) => msg.media !== null - - /** - * Filter text-only messages non-service messages - */ - export const text: UpdateFilter< - Message, - { - media: null - isService: false - } - > = (msg) => msg.media === null && !msg.isService - - /** - * Filter service messages - */ - export const service: UpdateFilter = (msg) => - msg.isService - - /** - * Filter service messages by action type - */ - export const action = ['type']>( - type: MaybeArray, - ): UpdateFilter< - Message, - { - action: Extract - sender: T extends - | 'user_joined_link' - | 'user_removed' - | 'history_cleared' - | 'contact_joined' - | 'bot_allowed' - ? User - : User | Chat - } - > => { - if (Array.isArray(type)) { - const index: Partial> = {} - type.forEach((it) => (index[it] = true)) - - return (msg) => (msg.action?.type as any) in index - } - - return (msg) => msg.action?.type === type - } - - /** - * Filter messages containing a photo - */ - export const photo: UpdateFilter = (msg) => - msg.media?.type === 'photo' - - /** - * Filter messages containing a dice - */ - export const dice: UpdateFilter = (msg) => - msg.media?.type === 'dice' - - /** - * Filter messages containing a contact - */ - export const contact: UpdateFilter = (msg) => - msg.media?.type === 'contact' - - /** - * Filter messages containing a document - * - * This will also match media like audio, video, voice - * that also use Documents - */ - export const anyDocument: UpdateFilter = ( - msg, - ) => msg.media instanceof RawDocument - - /** - * Filter messages containing a document in form of a file - * - * This will not match media like audio, video, voice - */ - export const document: UpdateFilter = (msg) => - msg.media?.type === 'document' - - /** - * Filter messages containing an audio file - */ - export const audio: UpdateFilter = (msg) => - msg.media?.type === 'audio' - - /** - * Filter messages containing a voice note - */ - export const voice: UpdateFilter = (msg) => - msg.media?.type === 'voice' - - /** - * Filter messages containing a sticker - */ - export const sticker: UpdateFilter = (msg) => - msg.media?.type === 'sticker' - - /** - * Filter messages containing a sticker by its type - */ - export const stickerByType = - (type: StickerType): UpdateFilter => - (msg) => - msg.media?.type === 'sticker' && msg.media.stickerType === type - - /** - * Filter messages containing a sticker by its source file type - */ - export const stickerBySourceType = - (type: StickerSourceType): UpdateFilter => - (msg) => - msg.media?.type === 'sticker' && msg.media.sourceType === type - - /** - * Filter messages containing a video. - * - * This includes videos, round messages and animations - */ - export const anyVideo: UpdateFilter = (msg) => - msg.media?.type === 'video' - - /** - * Filter messages containing a simple video. - * - * This does not include round messages and animations - */ - export const video: UpdateFilter< - Message, - { - media: Modify< - Video, - { - isRound: false - isAnimation: false - } - > - } - > = (msg) => - msg.media?.type === 'video' && - !msg.media.isAnimation && - !msg.media.isRound - - /** - * Filter messages containing an animation. - * - * > **Note**: Legacy GIFs (i.e. documents with `image/gif` MIME) - * > are also considered animations. - */ - export const animation: UpdateFilter< - Message, - { - media: Modify< - Video, - { - isRound: false - isAnimation: true - } - > - } - > = (msg) => - msg.media?.type === 'video' && - msg.media.isAnimation && - !msg.media.isRound - - /** - * Filter messages containing a round message (aka video note). - */ - export const roundMessage: UpdateFilter< - Message, - { - media: Modify< - Video, - { - isRound: true - isAnimation: false - } - > - } - > = (msg) => - msg.media?.type === 'video' && - !msg.media.isAnimation && - msg.media.isRound - - /** - * Filter messages containing any location (live or static). - */ - export const anyLocation: UpdateFilter = ( - msg, - ) => msg.media instanceof RawLocation - - /** - * Filter messages containing a static (non-live) location. - */ - export const location: UpdateFilter = ( - msg, - ) => msg.media?.type === 'location' - - /** - * Filter messages containing a live location. - */ - export const liveLocation: UpdateFilter< - Message, - { media: LiveLocation } - > = (msg) => msg.media?.type === 'live_location' - - /** - * Filter messages containing a game. - */ - export const game: UpdateFilter = (msg) => - msg.media?.type === 'game' - - /** - * Filter messages containing a webpage preview. - */ - export const webpage: UpdateFilter = (msg) => - msg.media?.type === 'web_page' - - /** - * Filter messages containing a venue. - */ - export const venue: UpdateFilter = (msg) => - msg.media?.type === 'venue' - - /** - * Filter messages containing a poll. - */ - export const poll: UpdateFilter = (msg) => - msg.media?.type === 'poll' - - /** - * Filter messages containing an invoice. - */ - export const invoice: UpdateFilter = (msg) => - msg.media?.type === 'invoice' - - /** - * Filter objects that match a given regular expression - * - for `Message`, `Message.text` is used - * - for `InlineQuery`, `InlineQuery.query` is used - * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used - * - for `CallbackQuery`, `CallbackQuery.dataStr` is used - * - * When a regex matches, the match array is stored in a - * type-safe extension field `.match` of the object - * - * @param regex Regex to be matched - */ - export const regex = - ( - regex: RegExp, - ): UpdateFilter< - Message | InlineQuery | ChosenInlineResult | CallbackQuery, - { match: RegExpMatchArray } - > => - (obj) => { - const txt = extractText(obj) - if (!txt) return false - - const m = txt.match(regex) - - if (m) { - (obj as typeof obj & { match: RegExpMatchArray }).match = m - - return true - } - - return false - } - - /** - * Filter objects which contain the exact text given - * - for `Message`, `Message.text` is used - * - for `InlineQuery`, `InlineQuery.query` is used - * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used - * - for `CallbackQuery`, `CallbackQuery.dataStr` is used - * - * @param str String to be matched - * @param ignoreCase Whether string case should be ignored - */ - export const equals = ( - str: string, - ignoreCase = false, - ): UpdateFilter< - Message | InlineQuery | ChosenInlineResult | CallbackQuery - > => { - if (ignoreCase) { - str = str.toLowerCase() - - return (obj) => extractText(obj)?.toLowerCase() === str - } - - return (obj) => extractText(obj) === str - } - - /** - * Filter objects which contain the text given (as a substring) - * - for `Message`, `Message.text` is used - * - for `InlineQuery`, `InlineQuery.query` is used - * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used - * - for `CallbackQuery`, `CallbackQuery.dataStr` is used - * - * @param str Substring to be matched - * @param ignoreCase Whether string case should be ignored - */ - export const contains = ( - str: string, - ignoreCase = false, - ): UpdateFilter< - Message | InlineQuery | ChosenInlineResult | CallbackQuery - > => { - if (ignoreCase) { - str = str.toLowerCase() - - return (obj) => { - const txt = extractText(obj) - - return txt != null && txt.toLowerCase().includes(str) - } - } - - return (obj) => { - const txt = extractText(obj) - - return txt != null && txt.includes(str) - } - } - - /** - * Filter objects which contain the text starting with a given string - * - for `Message`, `Message.text` is used - * - for `InlineQuery`, `InlineQuery.query` is used - * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used - * - for `CallbackQuery`, `CallbackQuery.dataStr` is used - * - * @param str Substring to be matched - * @param ignoreCase Whether string case should be ignored - */ - export const startsWith = ( - str: string, - ignoreCase = false, - ): UpdateFilter< - Message | InlineQuery | ChosenInlineResult | CallbackQuery - > => { - if (ignoreCase) { - str = str.toLowerCase() - - return (obj) => { - const txt = extractText(obj) - - return ( - txt != null && - txt.toLowerCase().substring(0, str.length) === str - ) - } - } - - return (obj) => { - const txt = extractText(obj) - - return txt != null && txt.substring(0, str.length) === str - } - } - - /** - * Filter objects which contain the text ending with a given string - * - for `Message`, `Message.text` is used - * - for `InlineQuery`, `InlineQuery.query` is used - * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used - * - for `CallbackQuery`, `CallbackQuery.dataStr` is used - * - * @param str Substring to be matched - * @param ignoreCase Whether string case should be ignored - */ - export const endsWith = ( - str: string, - ignoreCase = false, - ): UpdateFilter< - Message | InlineQuery | ChosenInlineResult | CallbackQuery - > => { - if (ignoreCase) { - str = str.toLowerCase() - - return (obj) => { - const txt = extractText(obj) - - return ( - txt != null && - txt.toLowerCase().substring(0, str.length) === str - ) - } - } - - return (obj) => { - const txt = extractText(obj) - - return txt != null && txt.substring(0, str.length) === str - } - } - - /** - * Filter messages that call the given command(s).. - * - * When a command matches, the match array is stored in a - * type-safe extension field `.commmand` of the {@link Message} object. - * First element is the command itself, then the arguments. - * - * If the matched command was a RegExp, the first element is the - * command, then the groups from the command regex, then the arguments. - * - * @param commands Command(s) the filter should look for (w/out prefix) - * @param prefixes - * Prefix(es) the filter should look for (default: `/`). - * Can be `null` to disable prefixes altogether - * @param caseSensitive - */ - export const command = ( - commands: MaybeArray, - prefixes: MaybeArray | null = '/', - caseSensitive = false, - ): UpdateFilter => { - if (!Array.isArray(commands)) commands = [commands] - - commands = commands.map((i) => - typeof i === 'string' ? i.toLowerCase() : i, - ) - - const argumentsRe = /(["'])(.*?)(? { - if (typeof cmd !== 'string') cmd = cmd.source - - commandsRe.push( - new RegExp( - `^(${cmd})(?:\\s|$|@([a-zA-Z0-9_]+?bot)(?:\\s|$))`, - caseSensitive ? '' : 'i', - ), - ) - }) - - if (prefixes === null) prefixes = [] - if (typeof prefixes === 'string') prefixes = [prefixes] - - const _prefixes = prefixes - - const check = (msg: Message): MaybeAsync => { - for (const pref of _prefixes) { - if (!msg.text.startsWith(pref)) continue - - const withoutPrefix = msg.text.slice(pref.length) - - for (const regex of commandsRe) { - const m = withoutPrefix.match(regex) - if (!m) continue - - const lastGroup = m[m.length - 1] - - // eslint-disable-next-line dot-notation - if (lastGroup && msg.client['_isBot']) { - // check bot username - // eslint-disable-next-line dot-notation - if (lastGroup !== msg.client['_selfUsername']) { - return false - } - } - - const match = m.slice(1, -1) - - // we use .replace to iterate over global regex, not to replace the text - withoutPrefix - .slice(m[0].length) - .replace( - argumentsRe, - ($0, $1, $2: string, $3: string) => { - match.push( - ($2 || $3 || '').replace(unescapeRe, '$1'), - ) - - return '' - }, - ) - ;(msg as Message & { command: string[] }).command = match - - return true - } - } - - return false - } - - return check - } - - /** - * Shorthand filter that matches /start commands sent to bot's - * private messages. - */ - export const start = and(chat('private'), command('start')) - - /** - * Filter for deep links (i.e. `/start `). - * - * If the parameter is a regex, groups are added to `msg.command`, - * meaning that the first group is available in `msg.command[2]`. - */ - export const deeplink = ( - params: MaybeArray, - ): UpdateFilter => { - if (!Array.isArray(params)) { - return and(start, (_msg: Message) => { - const msg = _msg as Message & { command: string[] } - - if (msg.command.length !== 2) return false - - const p = msg.command[1] - if (typeof params === 'string' && p === params) return true - - const m = p.match(params) - if (!m) return false - - msg.command.push(...m.slice(1)) - - return true - }) - } - - return and(start, (_msg: Message) => { - const msg = _msg as Message & { command: string[] } - - if (msg.command.length !== 2) return false - - const p = msg.command[1] - - for (const param of params) { - if (typeof param === 'string' && p === param) return true - - const m = p.match(param) - if (!m) continue - - msg.command.push(...m.slice(1)) - - return true - } - - return false - }) - } - - /** - * Create a filter for {@link ChatMemberUpdate} by update type - * - * @param types Update type(s) - * @link ChatMemberUpdate.Type - */ - export const chatMember: { - (type: T): UpdateFilter< - ChatMemberUpdate, - { type: T } - > - (types: T): UpdateFilter< - ChatMemberUpdate, - { type: T[number] } - > - } = ( - types: MaybeArray, - ): UpdateFilter => { - if (Array.isArray(types)) { - const index: Partial> = {} - types.forEach((typ) => (index[typ] = true)) - - return (upd) => upd.type in index - } - - return (upd) => upd.type === types - } - - /** - * Create a filter for {@link UserStatusUpdate} by new user status - * - * @param statuses Update type(s) - * @link User.Status - */ - export const userStatus: { - (status: T): UpdateFilter< - UserStatusUpdate, - { - type: T - lastOnline: T extends 'offline' ? Date : null - nextOffline: T extends 'online' ? Date : null - } - > - (statuses: T): UpdateFilter< - UserStatusUpdate, - { type: T[number] } - > - } = (statuses: MaybeArray): UpdateFilter => { - if (Array.isArray(statuses)) { - const index: Partial> = {} - statuses.forEach((typ) => (index[typ] = true)) - - return (upd) => upd.status in index - } - - return (upd) => upd.status === statuses - } - - /** - * Create a filter for {@link ChatMemberUpdate} for updates - * regarding current user - */ - export const chatMemberSelf: UpdateFilter< - ChatMemberUpdate, - { isSelf: true } - > = (upd) => upd.isSelf - - /** - * Create a filter for callback queries that - * originated from an inline message - */ - export const callbackInline: UpdateFilter< - CallbackQuery, - { isInline: true } - > = (q) => q.isInline - - /** - * Create a filter for the cases when the state is empty - */ - export const stateEmpty: UpdateFilter = async (upd, state) => { - if (!state) return false - - return !(await state.get()) - } - - /** - * Create a filter based on state predicate - * - * If state exists and matches `predicate`, update passes - * this filter, otherwise it doesn't - * - * @param predicate State predicate - */ - export const state = ( - predicate: (state: T) => MaybeAsync, - // eslint-disable-next-line @typescript-eslint/ban-types - ): UpdateFilter => { - return async (upd, state) => { - if (!state) return false - const data = await state.get() - if (!data) return false - - return predicate(data) - } - } -} diff --git a/packages/dispatcher/src/filters/bots.ts b/packages/dispatcher/src/filters/bots.ts new file mode 100644 index 00000000..954a2f72 --- /dev/null +++ b/packages/dispatcher/src/filters/bots.ts @@ -0,0 +1,150 @@ +import { Message } from '@mtcute/client' +import { MaybeArray, MaybeAsync } from '@mtcute/core' + +import { chat } from './chat' +import { and } from './logic' +import { UpdateFilter } from './types' + +/** + * Filter messages that call the given command(s).. + * + * When a command matches, the match array is stored in a + * type-safe extension field `.commmand` of the {@link Message} object. + * First element is the command itself, then the arguments. + * + * If the matched command was a RegExp, the first element is the + * command, then the groups from the command regex, then the arguments. + * + * @param commands Command(s) the filter should look for (w/out prefix) + * @param prefixes + * Prefix(es) the filter should look for (default: `/`). + * Can be `null` to disable prefixes altogether + * @param caseSensitive + */ +export const command = ( + commands: MaybeArray, + prefixes: MaybeArray | null = '/', + caseSensitive = false, +): UpdateFilter => { + if (!Array.isArray(commands)) commands = [commands] + + commands = commands.map((i) => + typeof i === 'string' ? i.toLowerCase() : i, + ) + + const argumentsRe = /(["'])(.*?)(? { + if (typeof cmd !== 'string') cmd = cmd.source + + commandsRe.push( + new RegExp( + `^(${cmd})(?:\\s|$|@([a-zA-Z0-9_]+?bot)(?:\\s|$))`, + caseSensitive ? '' : 'i', + ), + ) + }) + + if (prefixes === null) prefixes = [] + if (typeof prefixes === 'string') prefixes = [prefixes] + + const _prefixes = prefixes + + const check = (msg: Message): MaybeAsync => { + for (const pref of _prefixes) { + if (!msg.text.startsWith(pref)) continue + + const withoutPrefix = msg.text.slice(pref.length) + + for (const regex of commandsRe) { + const m = withoutPrefix.match(regex) + if (!m) continue + + const lastGroup = m[m.length - 1] + + // eslint-disable-next-line dot-notation + if (lastGroup && msg.client['_isBot']) { + // check bot username + // eslint-disable-next-line dot-notation + if (lastGroup !== msg.client['_selfUsername']) { + return false + } + } + + const match = m.slice(1, -1) + + // we use .replace to iterate over global regex, not to replace the text + withoutPrefix + .slice(m[0].length) + .replace(argumentsRe, ($0, $1, $2: string, $3: string) => { + match.push(($2 || $3 || '').replace(unescapeRe, '$1')) + + return '' + }) + ;(msg as Message & { command: string[] }).command = match + + return true + } + } + + return false + } + + return check +} + +/** + * Shorthand filter that matches /start commands sent to bot's + * private messages. + */ +export const start = and(chat('private'), command('start')) + +/** + * Filter for deep links (i.e. `/start `). + * + * If the parameter is a regex, groups are added to `msg.command`, + * meaning that the first group is available in `msg.command[2]`. + */ +export const deeplink = ( + params: MaybeArray, +): UpdateFilter => { + if (!Array.isArray(params)) { + return and(start, (_msg: Message) => { + const msg = _msg as Message & { command: string[] } + + if (msg.command.length !== 2) return false + + const p = msg.command[1] + if (typeof params === 'string' && p === params) return true + + const m = p.match(params) + if (!m) return false + + msg.command.push(...m.slice(1)) + + return true + }) + } + + return and(start, (_msg: Message) => { + const msg = _msg as Message & { command: string[] } + + if (msg.command.length !== 2) return false + + const p = msg.command[1] + + for (const param of params) { + if (typeof param === 'string' && p === param) return true + + const m = p.match(param) + if (!m) continue + + msg.command.push(...m.slice(1)) + + return true + } + + return false + }) +} diff --git a/packages/dispatcher/src/filters/bundle.ts b/packages/dispatcher/src/filters/bundle.ts new file mode 100644 index 00000000..a66b8413 --- /dev/null +++ b/packages/dispatcher/src/filters/bundle.ts @@ -0,0 +1,9 @@ +export * from './bots' +export * from './chat' +export * from './logic' +export * from './message' +export * from './state' +export * from './text' +export * from './types' +export * from './updates' +export * from './user' diff --git a/packages/dispatcher/src/filters/chat.ts b/packages/dispatcher/src/filters/chat.ts new file mode 100644 index 00000000..981be0ac --- /dev/null +++ b/packages/dispatcher/src/filters/chat.ts @@ -0,0 +1,86 @@ +import { Chat, ChatType, Message, PollVoteUpdate, User } from '@mtcute/client' +import { MaybeArray } from '@mtcute/core' + +import { Modify, UpdateFilter } from './types' + +/** + * Filter messages by chat type + */ +export const chat = + ( + type: T, + ): UpdateFilter< + Message, + { + chat: Modify + sender: T extends 'private' | 'bot' | 'group' ? User : User | Chat + } + > => + (msg) => + msg.chat.chatType === type + +/** + * Filter updates by chat ID(s) or username(s) + */ +export const chatId = ( + id: MaybeArray, +): UpdateFilter => { + if (Array.isArray(id)) { + const index: Record = {} + let matchSelf = false + id.forEach((id) => { + if (id === 'me' || id === 'self') { + matchSelf = true + } else { + index[id] = true + } + }) + + return (upd) => { + if (upd.constructor === PollVoteUpdate) { + const peer = upd.peer + + return peer.type === 'chat' && peer.id in index + } + + const chat = (upd as Exclude).chat + + return ( + (matchSelf && chat.isSelf) || + chat.id in index || + chat.username! in index + ) + } + } + + if (id === 'me' || id === 'self') { + return (upd) => { + if (upd.constructor === PollVoteUpdate) { + return upd.peer.type === 'chat' && upd.peer.isSelf + } + + return (upd as Exclude).chat.isSelf + } + } + + if (typeof id === 'string') { + return (upd) => { + if (upd.constructor === PollVoteUpdate) { + return upd.peer.type === 'chat' && upd.peer.username === id + } + + return ( + (upd as Exclude).chat.username === + id + ) + } + } + + return (upd) => { + if (upd.constructor === PollVoteUpdate) { + return upd.peer.type === 'chat' && upd.peer.id === id + } + + return (upd as Exclude).chat.id === id + } +} diff --git a/packages/dispatcher/src/filters/index.ts b/packages/dispatcher/src/filters/index.ts new file mode 100644 index 00000000..b25624e7 --- /dev/null +++ b/packages/dispatcher/src/filters/index.ts @@ -0,0 +1,3 @@ +import * as filters from './bundle' +import UpdateFilter = filters.UpdateFilter +export { filters, UpdateFilter } diff --git a/packages/dispatcher/src/filters/logic.ts b/packages/dispatcher/src/filters/logic.ts new file mode 100644 index 00000000..475777d3 --- /dev/null +++ b/packages/dispatcher/src/filters/logic.ts @@ -0,0 +1,219 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// ^^ will be looked into in MTQ-29 + +import { MaybeAsync } from '@mtcute/core' + +import { UpdateState } from '../state' +import { + ExtractBaseMany, + ExtractMod, + ExtractState, + Invert, + UnionToIntersection, + UpdateFilter, +} from './types' + +/** + * Filter that matches any update + */ +export const any: UpdateFilter = () => true + +/** + * Invert a filter by applying a NOT logical operation: + * `not(fn) = NOT fn` + * + * > **Note**: This also inverts type modification, i.e. + * > if the base is `{ field: string | number | null }` + * > and the modification is `{ field: string }`, + * > then the negated filter will have + * > inverted modification `{ field: number | null }` + * + * @param fn Filter to negate + */ +export function not( + fn: UpdateFilter, +): UpdateFilter, State> { + return (upd, state) => { + const res = fn(upd, state) + + if (typeof res === 'boolean') return !res + + return res.then((r) => !r) + } +} + +/** + * Combine two filters by applying an AND logical operation: + * `and(fn1, fn2) = fn1 AND fn2` + * + * > **Note**: This also combines type modifications, i.e. + * > if the 1st has modification `{ field1: string }` + * > and the 2nd has modification `{ field2: number }`, + * > 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. + * + * @param fns Filters to combine + */ +export function every[]>( + ...fns: Filters +): UpdateFilter< + ExtractBaseMany, + UnionToIntersection> +> { + if (fns.length === 2) return and(fns[0], fns[1]) + + return (upd, state) => { + let i = 0 + const max = fns.length + + const next = (): MaybeAsync => { + if (i === max) return true + + const res = fns[i++](upd, state) + + if (typeof res === 'boolean') { + if (!res) return false + + return next() + } + + return res.then((r: boolean) => { + if (!r) return false + + return next() + }) + } + + return next() + } +} + +/** + * Combine multiple filters by applying an OR logical + * operation between every one of them: + * `every(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 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. + * + * @param fns Filters to combine + */ +export function some[]>( + ...fns: Filters +): UpdateFilter< + ExtractBaseMany, + ExtractMod, + ExtractState +> { + if (fns.length === 2) return or(fns[0], fns[1]) + + return (upd, state) => { + let i = 0 + const max = fns.length + + const next = (): MaybeAsync => { + if (i === max) return false + + const res = fns[i++](upd, 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/message.ts b/packages/dispatcher/src/filters/message.ts new file mode 100644 index 00000000..a240a3d4 --- /dev/null +++ b/packages/dispatcher/src/filters/message.ts @@ -0,0 +1,227 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// ^^ will be looked into in MTQ-29 +import { + Chat, + Message, + MessageAction, + MessageMediaType, + RawDocument, + RawLocation, + Sticker, + StickerSourceType, + StickerType, + User, + Video, +} from '@mtcute/client' +import { MaybeArray } from '@mtcute/core' + +import { Modify, UpdateFilter } from './types' + +/** + * Filter incoming messages. + * + * Messages sent to yourself (i.e. Saved Messages) are also "incoming" + */ +export const incoming: UpdateFilter = (msg) => + !msg.isOutgoing + +/** + * Filter outgoing messages. + * + * Messages sent to yourself (i.e. Saved Messages) are **not** "outgoing" + */ +export const outgoing: UpdateFilter = (msg) => + msg.isOutgoing + +/** + * Filter messages that are replies to some other message + */ +export const reply: UpdateFilter = ( + msg, +) => msg.replyToMessageId !== null + +/** + * Filter messages containing some media + */ +export const media: UpdateFilter< + Message, + { media: Exclude } +> = (msg) => msg.media !== null + +/** + * Filter messages containing media of given type + */ +export const mediaOf = + ( + type: T, + ): UpdateFilter< + Message, + { media: Extract } + > => + (msg) => + msg.media?.type === type + +/** Filter messages containing a photo */ +export const photo = mediaOf('photo') +/** Filter messages containing a dice */ +export const dice = mediaOf('dice') +/** Filter messages containing a contact */ +export const contact = mediaOf('contact') +/** Filter messages containing an audio file */ +export const audio = mediaOf('audio') +/** Filter messages containing a voice message (audio-only) */ +export const voice = mediaOf('voice') +/** Filter messages containing a sticker */ +export const sticker = mediaOf('sticker') +/** Filter messages containing a document (a file) */ +export const document = mediaOf('document') +/** Filter messages containing any video (videos, round messages and animations) */ +export const anyVideo = mediaOf('video') +/** Filter messages containing a static location */ +export const location = mediaOf('location') +/** Filter messages containing a live location */ +export const liveLocation = mediaOf('live_location') +/** Filter messages containing a game */ +export const game = mediaOf('game') +/** Filter messages containing a web page */ +export const webpage = mediaOf('web_page') +/** Filter messages containing a venue */ +export const venue = mediaOf('venue') +/** Filter messages containing a poll */ +export const poll = mediaOf('poll') +/** Filter messages containing an invoice */ +export const invoice = mediaOf('invoice') + +/** + * Filter messages containing any location (live or static). + */ +export const anyLocation: UpdateFilter = (msg) => + msg.media instanceof RawLocation + +/** + * Filter messages containing a document + * + * This will also match media like audio, video, voice + * that also use Documents + */ +export const anyDocument: UpdateFilter = ( + msg, +) => msg.media instanceof RawDocument + +/** + * Filter messages containing a simple video. + * + * This does not include round messages and animations + */ +export const video: UpdateFilter< + Message, + { + media: Modify< + Video, + { + isRound: false + isAnimation: false + } + > + } +> = (msg) => + msg.media?.type === 'video' && !msg.media.isAnimation && !msg.media.isRound + +/** + * Filter messages containing an animation. + * + * > **Note**: Legacy GIFs (i.e. documents with `image/gif` MIME) + * > are also considered animations. + */ +export const animation: UpdateFilter< + Message, + { + media: Modify< + Video, + { + isRound: false + isAnimation: true + } + > + } +> = (msg) => + msg.media?.type === 'video' && msg.media.isAnimation && !msg.media.isRound + +/** + * Filter messages containing a round message (aka video note). + */ +export const roundMessage: UpdateFilter< + Message, + { + media: Modify< + Video, + { + isRound: true + isAnimation: false + } + > + } +> = (msg) => + msg.media?.type === 'video' && !msg.media.isAnimation && msg.media.isRound + +/** + * Filter messages containing a sticker by its type + */ +export const stickerByType = + (type: StickerType): UpdateFilter => + (msg) => + msg.media?.type === 'sticker' && msg.media.stickerType === type + +/** + * Filter messages containing a sticker by its source file type + */ +export const stickerBySourceType = + (type: StickerSourceType): UpdateFilter => + (msg) => + msg.media?.type === 'sticker' && msg.media.sourceType === type + +/** + * Filter text-only messages non-service messages + */ +export const text: UpdateFilter< + Message, + { + media: null + isService: false + } +> = (msg) => msg.media === null && !msg.isService + +/** + * Filter service messages + */ +export const service: UpdateFilter = (msg) => + msg.isService + +/** + * Filter service messages by action type + */ +export const action = ['type']>( + type: MaybeArray, +): UpdateFilter< + Message, + { + action: Extract + sender: T extends + | 'user_joined_link' + | 'user_removed' + | 'history_cleared' + | 'contact_joined' + | 'bot_allowed' + ? User + : User | Chat + } +> => { + if (Array.isArray(type)) { + const index: Partial> = {} + type.forEach((it) => (index[it] = true)) + + return (msg) => (msg.action?.type as any) in index + } + + return (msg) => msg.action?.type === type +} diff --git a/packages/dispatcher/src/filters/state.ts b/packages/dispatcher/src/filters/state.ts new file mode 100644 index 00000000..c1131a3f --- /dev/null +++ b/packages/dispatcher/src/filters/state.ts @@ -0,0 +1,34 @@ +import { CallbackQuery, Message } from '@mtcute/client' +import { MaybeAsync } from '@mtcute/core' + +import { UpdateFilter } from './types' + +/** + * Create a filter for the cases when the state is empty + */ +export const stateEmpty: UpdateFilter = async (upd, state) => { + if (!state) return false + + return !(await state.get()) +} + +/** + * Create a filter based on state predicate + * + * If state exists and matches `predicate`, update passes + * this filter, otherwise it doesn't + * + * @param predicate State predicate + */ +export const state = ( + predicate: (state: T) => MaybeAsync, + // eslint-disable-next-line @typescript-eslint/ban-types +): UpdateFilter => { + return async (upd, state) => { + if (!state) return false + const data = await state.get() + if (!data) return false + + return predicate(data) + } +} diff --git a/packages/dispatcher/src/filters/text.ts b/packages/dispatcher/src/filters/text.ts new file mode 100644 index 00000000..62cf4613 --- /dev/null +++ b/packages/dispatcher/src/filters/text.ts @@ -0,0 +1,182 @@ +// ^^ will be looked into in MTQ-29 + +import { + CallbackQuery, + ChosenInlineResult, + InlineQuery, + Message, +} from '@mtcute/client' + +import { UpdateFilter } from './types' + +function extractText( + obj: Message | InlineQuery | ChosenInlineResult | CallbackQuery, +): string | null { + if (obj.constructor === Message) { + return obj.text + } else if (obj.constructor === InlineQuery) { + return obj.query + } else if (obj.constructor === ChosenInlineResult) { + return obj.id + } else if (obj.constructor === CallbackQuery) { + if (obj.raw.data) return obj.dataStr + } + + return null +} + +/** + * Filter objects that match a given regular expression + * - for `Message`, `Message.text` is used + * - for `InlineQuery`, `InlineQuery.query` is used + * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used + * - for `CallbackQuery`, `CallbackQuery.dataStr` is used + * + * When a regex matches, the match array is stored in a + * type-safe extension field `.match` of the object + * + * @param regex Regex to be matched + */ +export const regex = + ( + regex: RegExp, + ): UpdateFilter< + Message | InlineQuery | ChosenInlineResult | CallbackQuery, + { match: RegExpMatchArray } + > => + (obj) => { + const txt = extractText(obj) + if (!txt) return false + + const m = txt.match(regex) + + if (m) { + (obj as typeof obj & { match: RegExpMatchArray }).match = m + + return true + } + + return false + } + +/** + * Filter objects which contain the exact text given + * - for `Message`, `Message.text` is used + * - for `InlineQuery`, `InlineQuery.query` is used + * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used + * - for `CallbackQuery`, `CallbackQuery.dataStr` is used + * + * @param str String to be matched + * @param ignoreCase Whether string case should be ignored + */ +export const equals = ( + str: string, + ignoreCase = false, +): UpdateFilter => { + if (ignoreCase) { + str = str.toLowerCase() + + return (obj) => extractText(obj)?.toLowerCase() === str + } + + return (obj) => extractText(obj) === str +} + +/** + * Filter objects which contain the text given (as a substring) + * - for `Message`, `Message.text` is used + * - for `InlineQuery`, `InlineQuery.query` is used + * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used + * - for `CallbackQuery`, `CallbackQuery.dataStr` is used + * + * @param str Substring to be matched + * @param ignoreCase Whether string case should be ignored + */ +export const contains = ( + str: string, + ignoreCase = false, +): UpdateFilter => { + if (ignoreCase) { + str = str.toLowerCase() + + return (obj) => { + const txt = extractText(obj) + + return txt != null && txt.toLowerCase().includes(str) + } + } + + return (obj) => { + const txt = extractText(obj) + + return txt != null && txt.includes(str) + } +} + +/** + * Filter objects which contain the text starting with a given string + * - for `Message`, `Message.text` is used + * - for `InlineQuery`, `InlineQuery.query` is used + * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used + * - for `CallbackQuery`, `CallbackQuery.dataStr` is used + * + * @param str Substring to be matched + * @param ignoreCase Whether string case should be ignored + */ +export const startsWith = ( + str: string, + ignoreCase = false, +): UpdateFilter => { + if (ignoreCase) { + str = str.toLowerCase() + + return (obj) => { + const txt = extractText(obj) + + return ( + txt != null && + txt.toLowerCase().substring(0, str.length) === str + ) + } + } + + return (obj) => { + const txt = extractText(obj) + + return txt != null && txt.substring(0, str.length) === str + } +} + +/** + * Filter objects which contain the text ending with a given string + * - for `Message`, `Message.text` is used + * - for `InlineQuery`, `InlineQuery.query` is used + * - for {@link ChosenInlineResult}, {@link ChosenInlineResult.id} is used + * - for `CallbackQuery`, `CallbackQuery.dataStr` is used + * + * @param str Substring to be matched + * @param ignoreCase Whether string case should be ignored + */ +export const endsWith = ( + str: string, + ignoreCase = false, +): UpdateFilter => { + if (ignoreCase) { + str = str.toLowerCase() + + return (obj) => { + const txt = extractText(obj) + + return ( + txt != null && + txt.toLowerCase().substring(0, str.length) === str + ) + } + } + + return (obj) => { + const txt = extractText(obj) + + return txt != null && txt.substring(0, str.length) === str + } +} diff --git a/packages/dispatcher/src/filters/types.ts b/packages/dispatcher/src/filters/types.ts new file mode 100644 index 00000000..db2d55fa --- /dev/null +++ b/packages/dispatcher/src/filters/types.ts @@ -0,0 +1,117 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// ^^ will be looked into in MTQ-29 + +import { MaybeAsync } from '@mtcute/core' + +import { UpdateState } from '../state' +/** + * Type describing a primitive filter, which is a function taking some `Base` + * and a {@link TelegramClient}, checking it against some condition + * and returning a boolean. + * + * If `true` is returned, the filter is considered + * to be matched, and the appropriate update handler function is called, + * otherwise next registered handler is checked. + * + * Additionally, filter might contain a type modification + * to `Base` for better code insights. If it is present, + * it is used to overwrite types (!) of some of the `Base` fields + * to given (note that this is entirely compile-time! object is not modified) + * + * For parametrized filters (like {@link filters.regex}), + * type modification can also be used to add additional fields + * (in case of `regex`, its match array is added to `.match`) + * + * Example without type mod: + * ```typescript + * + * const hasPhoto: UpdateFilter = msg => msg.media?.type === 'photo' + * + * // ..later.. + * tg.onNewMessage(hasPhoto, async (msg) => { + * // `hasPhoto` filter matched, so we can safely assume + * // that `msg.media` is a Photo. + * // + * // but it is very redundant, verbose and error-rome, + * // wonder if we could make typescript do this automagically and safely... + * await (msg.media as Photo).downloadToFile(`${msg.id}.jpg`) + * }) + * ``` + * + * Example with type mod: + * ```typescript + * + * const hasPhoto: UpdateFilter = msg => msg.media?.type === 'photo' + * + * // ..later.. + * tg.onNewMessage(hasPhoto, async (msg) => { + * // since `hasPhoto` filter matched, + * // we have applied the modification to `msg`, + * // and `msg.media` now has type `Photo` + * // + * // no more redundancy and type casts! + * await msg.media.downloadToFile(`${msg.id}.jpg`) + * }) + * ``` + * + * > **Note**: Type modification can contain anything, even totally unrelated types + * > and it is *your* task to keep track that everything is correct. + * > + * > Bad example: + * > ```typescript + * > // we check for `Photo`, but type contains `Audio`. this will be a problem! + * > const hasPhoto: UpdateFilter = msg => msg.media?.type === 'photo' + * > + * > // ..later.. + * > tg.onNewMessage(hasPhoto, async (msg) => { + * > // oops! `msg.media` is `Audio` and does not have `.width`! + * > console.log(msg.media.width) + * > }) + * > ``` + * + * > **Warning!** Do not use the generics provided in functions + * > like `and`, `or`, etc. Those are meant to be inferred by the compiler! + */ +// we need the second parameter because it carries meta information +// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-types +export type UpdateFilter = ( + update: Base, + state?: UpdateState +) => MaybeAsync + +export type Modify = Omit & Mod +export type Invert = { + [P in keyof Mod & keyof Base]: Exclude +} + +export type UnionToIntersection = ( + U extends any ? (k: U) => void : never +) extends (k: infer I) => void + ? I + : never + +export type ExtractBase = Filter extends UpdateFilter + ? I + : never + +export type ExtractMod = Filter extends UpdateFilter + ? I + : never + +export type ExtractState = Filter extends UpdateFilter< + any, + any, + infer I +> + ? I + : never + +export type TupleKeys = Exclude +export type WrapBase = { + [K in TupleKeys]: { base: ExtractBase } +} +export type Values = T[keyof T] +export type UnwrapBase = T extends { base: any } ? T['base'] : never +export type ExtractBaseMany = UnwrapBase< + UnionToIntersection>> +> diff --git a/packages/dispatcher/src/filters/updates.ts b/packages/dispatcher/src/filters/updates.ts new file mode 100644 index 00000000..4ba0efc7 --- /dev/null +++ b/packages/dispatcher/src/filters/updates.ts @@ -0,0 +1,85 @@ +import { + CallbackQuery, + ChatMemberUpdate, + ChatMemberUpdateType, + UserStatus, + UserStatusUpdate, +} from '@mtcute/client' +import { MaybeArray } from '@mtcute/core' + +import { UpdateFilter } from './types' + +/** + * Create a filter for {@link ChatMemberUpdate} by update type + * + * @param types Update type(s) + * @link ChatMemberUpdate.Type + */ +export const chatMember: { + (type: T): UpdateFilter< + ChatMemberUpdate, + { type: T } + > + (types: T): UpdateFilter< + ChatMemberUpdate, + { type: T[number] } + > +} = ( + types: MaybeArray, +): UpdateFilter => { + if (Array.isArray(types)) { + const index: Partial> = {} + types.forEach((typ) => (index[typ] = true)) + + return (upd) => upd.type in index + } + + return (upd) => upd.type === types +} + +/** + * Create a filter for {@link UserStatusUpdate} by new user status + * + * @param statuses Update type(s) + * @link User.Status + */ +export const userStatus: { + (status: T): UpdateFilter< + UserStatusUpdate, + { + type: T + lastOnline: T extends 'offline' ? Date : null + nextOffline: T extends 'online' ? Date : null + } + > + (statuses: T): UpdateFilter< + UserStatusUpdate, + { type: T[number] } + > +} = (statuses: MaybeArray): UpdateFilter => { + if (Array.isArray(statuses)) { + const index: Partial> = {} + statuses.forEach((typ) => (index[typ] = true)) + + return (upd) => upd.status in index + } + + return (upd) => upd.status === statuses +} + +/** + * Create a filter for {@link ChatMemberUpdate} for updates + * regarding current user + */ +export const chatMemberSelf: UpdateFilter< + ChatMemberUpdate, + { isSelf: true } +> = (upd) => upd.isSelf + +/** + * Create a filter for callback queries that + * originated from an inline message + */ +export const callbackInline: UpdateFilter = ( + q, +) => q.isInline diff --git a/packages/dispatcher/src/filters/user.ts b/packages/dispatcher/src/filters/user.ts new file mode 100644 index 00000000..81e76bab --- /dev/null +++ b/packages/dispatcher/src/filters/user.ts @@ -0,0 +1,196 @@ +import { + BotChatJoinRequestUpdate, + CallbackQuery, + ChatMemberUpdate, + ChosenInlineResult, + InlineQuery, + Message, + PollVoteUpdate, + User, + UserStatusUpdate, + UserTypingUpdate, +} from '@mtcute/client' +import { MaybeArray } from '@mtcute/core' + +import { UpdateFilter } from './types' + +/** + * Filter messages generated by yourself (including Saved Messages) + */ +export const me: UpdateFilter = (msg) => + (msg.sender.constructor === User && msg.sender.isSelf) || msg.isOutgoing + +/** + * Filter messages sent by bots + */ +export const bot: UpdateFilter = (msg) => + msg.sender.constructor === User && msg.sender.isBot + +/** + * Filter updates by user ID(s) or username(s) + * + * Usernames are not supported for UserStatusUpdate + * and UserTypingUpdate. + * + * + * For chat member updates, uses `user.id` + */ +export const userId = ( + id: MaybeArray, +): UpdateFilter< + | Message + | UserStatusUpdate + | UserTypingUpdate + | InlineQuery + | ChatMemberUpdate + | ChosenInlineResult + | CallbackQuery + | PollVoteUpdate + | BotChatJoinRequestUpdate +> => { + if (Array.isArray(id)) { + const index: Record = {} + let matchSelf = false + id.forEach((id) => { + if (id === 'me' || id === 'self') { + matchSelf = true + } else { + index[id] = true + } + }) + + return (upd) => { + const ctor = upd.constructor + + if (ctor === Message) { + const sender = (upd as Message).sender + + return ( + (matchSelf && sender.isSelf) || + sender.id in index || + sender.username! in index + ) + } else if (ctor === UserStatusUpdate || ctor === UserTypingUpdate) { + const id = (upd as UserStatusUpdate | UserTypingUpdate).userId + + return ( + // eslint-disable-next-line dot-notation + (matchSelf && id === upd.client['_userId']) || id in index + ) + } else if (ctor === PollVoteUpdate) { + const peer = (upd as PollVoteUpdate).peer + if (peer.type !== 'user') return false + + return ( + (matchSelf && peer.isSelf) || + peer.id in index || + peer.username! in index + ) + } + + const user = ( + upd as Exclude< + typeof upd, + | Message + | UserStatusUpdate + | UserTypingUpdate + | PollVoteUpdate + > + ).user + + return ( + (matchSelf && user.isSelf) || + user.id in index || + user.username! in index + ) + } + } + + if (id === 'me' || id === 'self') { + return (upd) => { + const ctor = upd.constructor + + if (ctor === Message) { + return (upd as Message).sender.isSelf + } else if (ctor === UserStatusUpdate || ctor === UserTypingUpdate) { + return ( + (upd as UserStatusUpdate | UserTypingUpdate).userId === + // eslint-disable-next-line dot-notation + upd.client['_userId'] + ) + } else if (ctor === PollVoteUpdate) { + const peer = (upd as PollVoteUpdate).peer + if (peer.type !== 'user') return false + + return peer.isSelf + } + + return ( + upd as Exclude< + typeof upd, + | Message + | UserStatusUpdate + | UserTypingUpdate + | PollVoteUpdate + > + ).user.isSelf + } + } + + if (typeof id === 'string') { + return (upd) => { + const ctor = upd.constructor + + if (ctor === Message) { + return (upd as Message).sender.username === id + } else if (ctor === UserStatusUpdate || ctor === UserTypingUpdate) { + // username is not available + return false + } else if (ctor === PollVoteUpdate) { + const peer = (upd as PollVoteUpdate).peer + if (peer.type !== 'user') return false + + return peer.username === id + } + + return ( + ( + upd as Exclude< + typeof upd, + | Message + | UserStatusUpdate + | UserTypingUpdate + | PollVoteUpdate + > + ).user.username === id + ) + } + } + + return (upd) => { + const ctor = upd.constructor + + if (ctor === Message) { + return (upd as Message).sender.id === id + } else if (ctor === UserStatusUpdate || ctor === UserTypingUpdate) { + return (upd as UserStatusUpdate | UserTypingUpdate).userId === id + } else if (ctor === PollVoteUpdate) { + const peer = (upd as PollVoteUpdate).peer + if (peer.type !== 'user') return false + + return peer.id === id + } + + return ( + ( + upd as Exclude< + typeof upd, + | Message + | UserStatusUpdate + | UserTypingUpdate + | PollVoteUpdate + > + ).user.id === id + ) + } +}