refactor: extracted dispatcher filters into multiple files

This commit is contained in:
alina 🌸 2023-09-21 14:48:08 +03:00
parent a3ebd3fc66
commit 6e8351ac01
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
13 changed files with 1309 additions and 1342 deletions

View file

@ -40,6 +40,7 @@ export type MessageMedia =
| Poll
| Invoice
| null
export type MessageMediaType = Exclude<MessageMedia, null>['type']
// todo: successful_payment, connected_website

File diff suppressed because it is too large Load diff

View file

@ -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<string | RegExp>,
prefixes: MaybeArray<string> | null = '/',
caseSensitive = false,
): UpdateFilter<Message, { command: string[] }> => {
if (!Array.isArray(commands)) commands = [commands]
commands = commands.map((i) =>
typeof i === 'string' ? i.toLowerCase() : i,
)
const argumentsRe = /(["'])(.*?)(?<!\\)\1|(\S+)/g
const unescapeRe = /\\(['"])/
const commandsRe: RegExp[] = []
commands.forEach((cmd) => {
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<boolean> => {
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 <deeplink_parameter>`).
*
* 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<string | RegExp>,
): UpdateFilter<Message, { command: string[] }> => {
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
})
}

View file

@ -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'

View file

@ -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 =
<T extends ChatType>(
type: T,
): UpdateFilter<
Message,
{
chat: Modify<Chat, { type: T }>
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<number | string>,
): UpdateFilter<Message | PollVoteUpdate> => {
if (Array.isArray(id)) {
const index: Record<number | string, true> = {}
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<typeof upd, PollVoteUpdate>).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<typeof upd, PollVoteUpdate>).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<typeof upd, PollVoteUpdate>).chat.username ===
id
)
}
}
return (upd) => {
if (upd.constructor === PollVoteUpdate) {
return upd.peer.type === 'chat' && upd.peer.id === id
}
return (upd as Exclude<typeof upd, PollVoteUpdate>).chat.id === id
}
}

View file

@ -0,0 +1,3 @@
import * as filters from './bundle'
import UpdateFilter = filters.UpdateFilter
export { filters, UpdateFilter }

View file

@ -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<any> = () => 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<Base, Mod, State>(
fn: UpdateFilter<Base, Mod, State>,
): UpdateFilter<Base, Invert<Base, Mod>, 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<Base, Mod1, Mod2, State1, State2>(
fn1: UpdateFilter<Base, Mod1, State1>,
fn2: UpdateFilter<Base, Mod2, State2>,
): UpdateFilter<Base, Mod1 & Mod2, State1 | State2> {
return (upd, state) => {
const res1 = fn1(upd, state as UpdateState<State1>)
if (typeof res1 === 'boolean') {
if (!res1) return false
return fn2(upd, state as UpdateState<State2>)
}
return res1.then((r1) => {
if (!r1) return false
return fn2(upd, state as UpdateState<State2>)
})
}
}
/**
* 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<Base, Mod1, Mod2, State1, State2>(
fn1: UpdateFilter<Base, Mod1, State1>,
fn2: UpdateFilter<Base, Mod2, State2>,
): UpdateFilter<Base, Mod1 | Mod2, State1 | State2> {
return (upd, state) => {
const res1 = fn1(upd, state as UpdateState<State1>)
if (typeof res1 === 'boolean') {
if (res1) return true
return fn2(upd, state as UpdateState<State2>)
}
return res1.then((r1) => {
if (r1) return true
return fn2(upd, state as UpdateState<State2>)
})
}
}
// 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<Filters extends UpdateFilter<any, any>[]>(
...fns: Filters
): UpdateFilter<
ExtractBaseMany<Filters>,
UnionToIntersection<ExtractMod<Filters[number]>>
> {
if (fns.length === 2) return and(fns[0], fns[1])
return (upd, state) => {
let i = 0
const max = fns.length
const next = (): MaybeAsync<boolean> => {
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<Filters extends UpdateFilter<any, any, any>[]>(
...fns: Filters
): UpdateFilter<
ExtractBaseMany<Filters>,
ExtractMod<Filters[number]>,
ExtractState<Filters[number]>
> {
if (fns.length === 2) return or(fns[0], fns[1])
return (upd, state) => {
let i = 0
const max = fns.length
const next = (): MaybeAsync<boolean> => {
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()
}
}

View file

@ -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<Message, { isOutgoing: false }> = (msg) =>
!msg.isOutgoing
/**
* Filter outgoing messages.
*
* Messages sent to yourself (i.e. Saved Messages) are **not** "outgoing"
*/
export const outgoing: UpdateFilter<Message, { isOutgoing: true }> = (msg) =>
msg.isOutgoing
/**
* Filter messages that are replies to some other message
*/
export const reply: UpdateFilter<Message, { replyToMessageId: number }> = (
msg,
) => msg.replyToMessageId !== null
/**
* Filter messages containing some media
*/
export const media: UpdateFilter<
Message,
{ media: Exclude<Message['media'], null> }
> = (msg) => msg.media !== null
/**
* Filter messages containing media of given type
*/
export const mediaOf =
<T extends MessageMediaType>(
type: T,
): UpdateFilter<
Message,
{ media: Extract<Message['media'], { type: T }> }
> =>
(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<Message, { media: Location }> = (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<Message, { media: RawDocument }> = (
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<Message, { media: Sticker }> =>
(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<Message, { media: Sticker }> =>
(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<Message, { isService: true }> = (msg) =>
msg.isService
/**
* Filter service messages by action type
*/
export const action = <T extends Exclude<MessageAction, null>['type']>(
type: MaybeArray<T>,
): UpdateFilter<
Message,
{
action: Extract<MessageAction, { type: T }>
sender: T extends
| 'user_joined_link'
| 'user_removed'
| 'history_cleared'
| 'contact_joined'
| 'bot_allowed'
? User
: User | Chat
}
> => {
if (Array.isArray(type)) {
const index: Partial<Record<T, true>> = {}
type.forEach((it) => (index[it] = true))
return (msg) => (msg.action?.type as any) in index
}
return (msg) => msg.action?.type === type
}

View file

@ -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<Message> = 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 = <T>(
predicate: (state: T) => MaybeAsync<boolean>,
// eslint-disable-next-line @typescript-eslint/ban-types
): UpdateFilter<Message | CallbackQuery, {}, T> => {
return async (upd, state) => {
if (!state) return false
const data = await state.get()
if (!data) return false
return predicate(data)
}
}

View file

@ -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<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
}
}

View file

@ -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<Message> = 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<Message, { media: Photo }> = 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<Message, { media: Audio }> = 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<Base, Mod = {}, State = never> = (
update: Base,
state?: UpdateState<State>
) => MaybeAsync<boolean>
export type Modify<Base, Mod> = Omit<Base, keyof Mod> & Mod
export type Invert<Base, Mod> = {
[P in keyof Mod & keyof Base]: Exclude<Base[P], Mod[P]>
}
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never
export type ExtractBase<Filter> = Filter extends UpdateFilter<infer I, any>
? I
: never
export type ExtractMod<Filter> = Filter extends UpdateFilter<any, infer I>
? I
: never
export type ExtractState<Filter> = Filter extends UpdateFilter<
any,
any,
infer I
>
? I
: never
export type TupleKeys<T extends any[]> = Exclude<keyof T, keyof []>
export type WrapBase<T extends any[]> = {
[K in TupleKeys<T>]: { base: ExtractBase<T[K]> }
}
export type Values<T> = T[keyof T]
export type UnwrapBase<T> = T extends { base: any } ? T['base'] : never
export type ExtractBaseMany<Filters extends any[]> = UnwrapBase<
UnionToIntersection<Values<WrapBase<Filters>>>
>

View file

@ -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: {
<T extends ChatMemberUpdateType>(type: T): UpdateFilter<
ChatMemberUpdate,
{ type: T }
>
<T extends ChatMemberUpdateType[]>(types: T): UpdateFilter<
ChatMemberUpdate,
{ type: T[number] }
>
} = (
types: MaybeArray<ChatMemberUpdateType>,
): UpdateFilter<ChatMemberUpdate> => {
if (Array.isArray(types)) {
const index: Partial<Record<ChatMemberUpdateType, true>> = {}
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: {
<T extends UserStatus>(status: T): UpdateFilter<
UserStatusUpdate,
{
type: T
lastOnline: T extends 'offline' ? Date : null
nextOffline: T extends 'online' ? Date : null
}
>
<T extends UserStatus[]>(statuses: T): UpdateFilter<
UserStatusUpdate,
{ type: T[number] }
>
} = (statuses: MaybeArray<UserStatus>): UpdateFilter<UserStatusUpdate> => {
if (Array.isArray(statuses)) {
const index: Partial<Record<UserStatus, true>> = {}
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

View file

@ -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<Message, { sender: User }> = (msg) =>
(msg.sender.constructor === User && msg.sender.isSelf) || msg.isOutgoing
/**
* Filter messages sent by bots
*/
export const bot: UpdateFilter<Message, { sender: User }> = (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<number | string>,
): UpdateFilter<
| Message
| UserStatusUpdate
| UserTypingUpdate
| InlineQuery
| ChatMemberUpdate
| ChosenInlineResult
| CallbackQuery
| PollVoteUpdate
| BotChatJoinRequestUpdate
> => {
if (Array.isArray(id)) {
const index: Record<number | string, true> = {}
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
)
}
}