feat: message groups

This commit is contained in:
alina 🌸 2023-10-06 01:47:45 +03:00
parent 85ca3b4603
commit c7d82d41f0
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
13 changed files with 504 additions and 205 deletions

View file

@ -631,50 +631,7 @@ on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this
)
output.write('}\n')
output.write(
`
export interface TelegramClientOptions extends BaseTelegramClientOptions {
/**
* **ADVANCED**
*
* Whether to disable no-dispatch mechanism.
*
* No-dispatch is a mechanism that allows you to call methods
* that return updates and correctly handle them, without
* actually dispatching them to the event handlers.
*
* In other words, the following code will work differently:
* \`\`\`ts
* dp.onNewMessage(console.log)
* console.log(tg.sendText('me', 'hello'))
* \`\`\`
* - if \`disableNoDispatch\` is \`true\`, the sent message will be
* dispatched to the event handler, thus it will be printed twice
* - if \`disableNoDispatch\` is \`false\`, the sent message will not be
* dispatched to the event handler, thus it will onlt be printed once
*
* Disabling it also may improve performance, but it's not guaranteed.
*
* @default false
*/
disableNoDispatch?: boolean
/**
* Limit of {@link resolvePeerMany} internal async pool.
*
* Higher value means more parallel requests, but also
* higher risk of getting flood-wait errors.
* Most resolves will however likely be a DB cache hit.
*
* Only change this if you know what you're doing.
*
* @default 8
*/
resolvePeerManyPoolLimit?: number
}
`.trim(),
)
output.write('\nexport { TelegramClientOptions }\n')
output.write('\nexport class TelegramClient extends BaseTelegramClient {\n')
state.fields.forEach(({ code }) => output.write(`protected ${code}\n`))

View file

@ -8,16 +8,12 @@ const snakeToCamel = (s) => {
})
}
const camelToPascal = (s) =>
s[0].toUpperCase() + s.substr(1)
const camelToPascal = (s) => s[0].toUpperCase() + s.substr(1)
const camelToSnake = (s) => {
return s.replace(
/(?<=[a-zA-Z0-9])([A-Z0-9]+(?=[A-Z]|$)|[A-Z0-9])/g,
($1) => {
return '_' + $1.toLowerCase()
},
)
return s.replace(/(?<=[a-zA-Z0-9])([A-Z0-9]+(?=[A-Z]|$)|[A-Z0-9])/g, ($1) => {
return '_' + $1.toLowerCase()
})
}
function parseUpdateTypes() {
@ -30,15 +26,13 @@ function parseUpdateTypes() {
const ret = []
for (const line of lines) {
const m = line.match(/^([a-z_]+)(?:: ([a-zA-Z]+))? = ([a-zA-Z]+)( \+ State)?$/)
const m = line.match(/^([a-z_]+)(?:: ([a-zA-Z]+))? = ([a-zA-Z]+(?:\[\])?)( \+ State)?$/)
if (!m) throw new Error(`invalid syntax: ${line}`)
ret.push({
typeName: m[1],
handlerTypeName: m[2] || camelToPascal(snakeToCamel(m[1])),
updateType: m[3],
funcName: m[2] ?
m[2][0].toLowerCase() + m[2].substr(1) :
snakeToCamel(m[1]),
funcName: m[2] ? m[2][0].toLowerCase() + m[2].substr(1) : snakeToCamel(m[1]),
state: Boolean(m[4]),
})
}
@ -47,9 +41,7 @@ function parseUpdateTypes() {
}
function replaceSections(filename, sections, dir = __dirname) {
let lines = fs
.readFileSync(path.join(dir, '../src', filename), 'utf-8')
.split('\n')
let lines = fs.readFileSync(path.join(dir, '../src', filename), 'utf-8').split('\n')
const findMarker = (marker) => {
const idx = lines.findIndex((line) => line.trim() === `// ${marker}`)
@ -84,9 +76,7 @@ async function formatFile(filename, dir = __dirname) {
}
function toSentence(type, stype = 'inline') {
const name = camelToSnake(type.handlerTypeName)
.toLowerCase()
.replace(/_/g, ' ')
const name = camelToSnake(type.handlerTypeName).toLowerCase().replace(/_/g, ' ')
if (stype === 'inline') {
return `${name[0].match(/[aeiouy]/i) ? 'an' : 'a'} ${name} handler`
@ -99,7 +89,8 @@ function toSentence(type, stype = 'inline') {
function generateParsedUpdate() {
replaceSections('types/updates/index.ts', {
codegen: 'export type ParsedUpdate =\n' +
codegen:
'export type ParsedUpdate =\n' +
types.map((typ) => ` | { name: '${typ.typeName}'; data: ${typ.updateType} }\n`).join(''),
})
}

View file

@ -1,6 +1,7 @@
# format: type_name[: handler_type_name] = update_type[ + State]
new_message = Message + State
edit_message = Message + State
message_group = Message[] + State
delete_message = DeleteMessageUpdate
chat_member: ChatMemberUpdate = ChatMemberUpdate
inline_query = InlineQuery

View file

@ -329,6 +329,66 @@ import {
} from './types'
import { RpsMeter } from './utils/rps-meter'
// from methods/_options.ts
interface TelegramClientOptions extends BaseTelegramClientOptions {
/**
* **ADVANCED**
*
* Whether to disable no-dispatch mechanism.
*
* No-dispatch is a mechanism that allows you to call methods
* that return updates and correctly handle them, without
* actually dispatching them to the event handlers.
*
* In other words, the following code will work differently:
* ```ts
* dp.onNewMessage(console.log)
* console.log(await tg.sendText('me', 'hello'))
* ```
* - if `disableNoDispatch` is `true`, the sent message will be
* dispatched to the event handler, thus it will be printed twice
* - if `disableNoDispatch` is `false`, the sent message will not be
* dispatched to the event handler, thus it will onlt be printed once
*
* Disabling it also may improve performance, but it's not guaranteed.
*
* @default false
*/
disableNoDispatch?: boolean
/**
* Limit of {@link resolvePeerMany} internal async pool.
*
* Higher value means more parallel requests, but also
* higher risk of getting flood-wait errors.
* Most resolves will however likely be a DB cache hit.
*
* Only change this if you know what you're doing.
*
* @default 8
*/
resolvePeerManyPoolLimit?: number
/**
* When non-zero, allows the library to automatically handle Telegram
* media groups (e.g. albums) in {@link MessageGroup} updates
* in a given time interval (in ms).
*
* **Note**: this does not catch messages that happen to be consecutive,
* only messages belonging to the same "media group".
*
* This will cause up to `messageGroupingInterval` delay
* in handling media group messages.
*
* This option only applies to `new_message` updates,
* and the updates being grouped **will not** be dispatched on their own.
*
* Recommended value is 250 ms.
*
* @default 0 (disabled)
*/
messageGroupingInterval?: number
}
// from methods/updates.ts
interface PendingUpdateContainer {
upd: tl.TypeUpdates
@ -5482,45 +5542,9 @@ export interface TelegramClient extends BaseTelegramClient {
bio?: string
}): Promise<User>
}
export interface TelegramClientOptions extends BaseTelegramClientOptions {
/**
* **ADVANCED**
*
* Whether to disable no-dispatch mechanism.
*
* No-dispatch is a mechanism that allows you to call methods
* that return updates and correctly handle them, without
* actually dispatching them to the event handlers.
*
* In other words, the following code will work differently:
* ```ts
* dp.onNewMessage(console.log)
* console.log(tg.sendText('me', 'hello'))
* ```
* - if `disableNoDispatch` is `true`, the sent message will be
* dispatched to the event handler, thus it will be printed twice
* - if `disableNoDispatch` is `false`, the sent message will not be
* dispatched to the event handler, thus it will onlt be printed once
*
* Disabling it also may improve performance, but it's not guaranteed.
*
* @default false
*/
disableNoDispatch?: boolean
/**
* Limit of {@link resolvePeerMany} internal async pool.
*
* Higher value means more parallel requests, but also
* higher risk of getting flood-wait errors.
* Most resolves will however likely be a DB cache hit.
*
* Only change this if you know what you're doing.
*
* @default 8
*/
resolvePeerManyPoolLimit?: number
}
export { TelegramClientOptions }
export class TelegramClient extends BaseTelegramClient {
protected _userId: number | null
protected _isBot: boolean
@ -5544,6 +5568,8 @@ export class TelegramClient extends BaseTelegramClient {
protected _updLock: AsyncLock
protected _rpsIncoming?: RpsMeter
protected _rpsProcessing?: RpsMeter
protected _messageGroupingInterval: number
protected _messageGroupingPending: Map<string, [Message[], NodeJS.Timeout]>
protected _pts?: number
protected _qts?: number
protected _date?: number
@ -5583,6 +5609,9 @@ export class TelegramClient extends BaseTelegramClient {
this._noDispatchPts = new Map()
this._noDispatchQts = new Set()
this._messageGroupingInterval = opts.messageGroupingInterval ?? 0
this._messageGroupingPending = new Map()
this._updLock = new AsyncLock()
// we dont need to initialize state fields since
// they are always loaded either from the server, or from storage.

View file

@ -0,0 +1,64 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { BaseTelegramClientOptions } from '@mtcute/core'
// @copy
interface TelegramClientOptions extends BaseTelegramClientOptions {
/**
* **ADVANCED**
*
* Whether to disable no-dispatch mechanism.
*
* No-dispatch is a mechanism that allows you to call methods
* that return updates and correctly handle them, without
* actually dispatching them to the event handlers.
*
* In other words, the following code will work differently:
* ```ts
* dp.onNewMessage(console.log)
* console.log(await tg.sendText('me', 'hello'))
* ```
* - if `disableNoDispatch` is `true`, the sent message will be
* dispatched to the event handler, thus it will be printed twice
* - if `disableNoDispatch` is `false`, the sent message will not be
* dispatched to the event handler, thus it will onlt be printed once
*
* Disabling it also may improve performance, but it's not guaranteed.
*
* @default false
*/
disableNoDispatch?: boolean
/**
* Limit of {@link resolvePeerMany} internal async pool.
*
* Higher value means more parallel requests, but also
* higher risk of getting flood-wait errors.
* Most resolves will however likely be a DB cache hit.
*
* Only change this if you know what you're doing.
*
* @default 8
*/
resolvePeerManyPoolLimit?: number
/**
* When non-zero, allows the library to automatically handle Telegram
* media groups (e.g. albums) in {@link MessageGroup} updates
* in a given time interval (in ms).
*
* **Note**: this does not catch messages that happen to be consecutive,
* only messages belonging to the same "media group".
*
* This will cause up to `messageGroupingInterval` delay
* in handling media group messages.
*
* This option only applies to `new_message` updates,
* and the updates being grouped **will not** be dispatched on their own.
*
* Recommended value is 250 ms.
*
* @default 0 (disabled)
*/
messageGroupingInterval?: number
}

View file

@ -13,7 +13,7 @@ import {
} from '@mtcute/core/utils'
import { TelegramClient, TelegramClientOptions } from '../client'
import { PeersIndex } from '../types'
import { Message, PeersIndex } from '../types'
import { _parseUpdate } from '../types/updates/parse-update'
import { extractChannelIdFromUpdate } from '../utils/misc-utils'
import { normalizeToInputChannel } from '../utils/peer-utils'
@ -65,6 +65,9 @@ interface UpdatesState {
_rpsIncoming?: RpsMeter
_rpsProcessing?: RpsMeter
_messageGroupingInterval: number
_messageGroupingPending: Map<string, [Message[], NodeJS.Timeout]>
// accessing storage every time might be expensive,
// so store everything here, and load & save
// every time session is loaded & saved.
@ -109,6 +112,9 @@ function _initializeUpdates(this: TelegramClient, opts: TelegramClientOptions) {
this._noDispatchPts = new Map()
this._noDispatchQts = new Set()
this._messageGroupingInterval = opts.messageGroupingInterval ?? 0
this._messageGroupingPending = new Map()
this._updLock = new AsyncLock()
// we dont need to initialize state fields since
// they are always loaded either from the server, or from storage.
@ -375,6 +381,29 @@ export function _dispatchUpdate(this: TelegramClient, update: tl.TypeUpdate, pee
const parsed = _parseUpdate(this, update, peers)
if (parsed) {
if (this._messageGroupingInterval && parsed.name === 'new_message') {
const group = parsed.data.groupedIdUnique
if (group) {
const pendingGroup = this._messageGroupingPending.get(group)
if (pendingGroup) {
pendingGroup[0].push(parsed.data)
} else {
const messages = [parsed.data]
const timeout = setTimeout(() => {
this._messageGroupingPending.delete(group)
this.emit('update', { name: 'message_group', data: messages })
this.emit('message_group', messages)
}, this._messageGroupingInterval)
this._messageGroupingPending.set(group, [messages, timeout])
}
return
}
}
this.emit('update', parsed)
this.emit(parsed.name, parsed.data)
}

View file

@ -161,6 +161,15 @@ export class Message {
return this.raw._ === 'message' ? this.raw.groupedId ?? null : null
}
/**
* Same as {@link groupedId}, but is globally unique across chats.
*/
get groupedIdUnique(): string | null {
if (!(this.raw._ === 'message' && this.raw.groupedId !== undefined)) return null
return `${this.raw.groupedId.low}|${this.raw.groupedId.high}|${getMarkedPeerId(this.raw.peerId)}`
}
private _sender?: User | Chat
/**

View file

@ -36,6 +36,7 @@ export {
export type ParsedUpdate =
| { name: 'new_message'; data: Message }
| { name: 'edit_message'; data: Message }
| { name: 'message_group'; data: Message[] }
| { name: 'delete_message'; data: DeleteMessageUpdate }
| { name: 'chat_member'; data: ChatMemberUpdate }
| { name: 'inline_query'; data: InlineQuery }

View file

@ -41,6 +41,7 @@ import {
EditMessageHandler,
HistoryReadHandler,
InlineQueryHandler,
MessageGroupHandler,
NewMessageHandler,
PollUpdateHandler,
PollVoteHandler,
@ -109,7 +110,10 @@ export class Dispatcher<State = never, SceneName extends string = string> {
* Create a new dispatcher and bind it to client and optionally
* FSM storage
*/
constructor(client: TelegramClient, ...args: State extends never ? [] : [IStateStorage, StateKeyDelegate?])
constructor(
client: TelegramClient,
...args: (() => State) extends () => never ? [] : [IStateStorage, StateKeyDelegate?]
)
constructor(
client?: TelegramClient | IStateStorage | StateKeyDelegate,
storage?: IStateStorage | StateKeyDelegate,
@ -290,11 +294,16 @@ export class Dispatcher<State = never, SceneName extends string = string> {
if (
this._storage &&
this._scenes &&
(update.name === 'new_message' || update.name === 'edit_message' || update.name === 'callback_query')
(update.name === 'new_message' ||
update.name === 'edit_message' ||
update.name === 'callback_query' ||
update.name === 'message_group')
) {
// no need to fetch scene if there are no registered scenes
const key = await this._stateKeyDelegate!(update.data)
const key = await this._stateKeyDelegate!(
update.name === 'message_group' ? update.data[0] : update.data,
)
if (key) {
parsedScene = await this._storage.getCurrentScene(key)
@ -1034,6 +1043,57 @@ export class Dispatcher<State = never, SceneName extends string = string> {
this._addKnownHandler('edit_message', filter, handler, group)
}
/**
* Register a message group handler without any filters
*
* @param handler Message group handler
* @param group Handler group index
*/
onMessageGroup(
handler: MessageGroupHandler<
Message[],
State extends never ? never : UpdateState<State, SceneName>
>['callback'],
group?: number,
): void
/**
* Register a message group handler with a filter
*
* @param filter Update filter
* @param handler Message group handler
* @param group Handler group index
*/
onMessageGroup<Mod>(
filter: UpdateFilter<Message[], Mod, State>,
handler: MessageGroupHandler<
filters.Modify<Message[], Mod>,
State extends never ? never : UpdateState<State, SceneName>
>['callback'],
group?: number,
): void
/**
* Register a message group handler with a filter
*
* @param filter Update filter
* @param handler Message group handler
* @param group Handler group index
*/
onMessageGroup<Mod>(
filter: UpdateFilter<Message[], Mod>,
handler: MessageGroupHandler<
filters.Modify<Message[], Mod>,
State extends never ? never : UpdateState<State, SceneName>
>['callback'],
group?: number,
): void
/** @internal */
onMessageGroup(filter: any, handler?: any, group?: number): void {
this._addKnownHandler('message_group', filter, handler, group)
}
/**
* Register a delete message handler without any filters
*

View file

@ -3,8 +3,7 @@
import { MaybeAsync } from '@mtcute/core'
import { UpdateState } from '../state'
import { ExtractBaseMany, ExtractMod, ExtractState, Invert, UnionToIntersection, UpdateFilter } from './types'
import { ExtractBaseMany, ExtractMod, Invert, UnionToIntersection, UpdateFilter } from './types'
/**
* Filter that matches any update
@ -35,9 +34,90 @@ export function not<Base, Mod, State>(
}
}
// i couldn't come up with proper types for these 😭
// if you know how to do this better - PRs are welcome!
export function and<Base1, Mod1, State1, Base2, Mod2, State2>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
): UpdateFilter<Base1 & Base2, Mod1 & Mod2, State1 | State2>
export function and<Base1, Mod1, State1, Base2, Mod2, State2, Base3, Mod3, State3>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
fn3: UpdateFilter<Base3, Mod3, State3>,
): UpdateFilter<Base1 & Base2 & Base3, Mod1 & Mod2 & Mod3, State1 | State2 | State3>
export function and<Base1, Mod1, State1, Base2, Mod2, State2, Base3, Mod3, State3, Base4, Mod4, State4>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
fn3: UpdateFilter<Base3, Mod3, State3>,
fn4: UpdateFilter<Base4, Mod4, State4>,
): UpdateFilter<Base1 & Base2 & Base3 & Base4, Mod1 & Mod2 & Mod3 & Mod4, State1 | State2 | State3 | State4>
export function and<
Base1,
Mod1,
State1,
Base2,
Mod2,
State2,
Base3,
Mod3,
State3,
Base4,
Mod4,
State4,
Base5,
Mod5,
State5,
>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
fn3: UpdateFilter<Base3, Mod3, State3>,
fn4: UpdateFilter<Base4, Mod4, State4>,
fn5: UpdateFilter<Base5, Mod5, State5>,
): UpdateFilter<
Base1 & Base2 & Base3 & Base4 & Base5,
Mod1 & Mod2 & Mod3 & Mod4 & Mod5,
State1 | State2 | State3 | State4 | State5
>
export function and<
Base1,
Mod1,
State1,
Base2,
Mod2,
State2,
Base3,
Mod3,
State3,
Base4,
Mod4,
State4,
Base5,
Mod5,
State5,
Base6,
Mod6,
State6,
>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
fn3: UpdateFilter<Base3, Mod3, State3>,
fn4: UpdateFilter<Base4, Mod4, State4>,
fn5: UpdateFilter<Base5, Mod5, State5>,
fn6: UpdateFilter<Base6, Mod6, State6>,
): UpdateFilter<
Base1 & Base2 & Base3 & Base4 & Base5 & Base6,
Mod1 & Mod2 & Mod3 & Mod4 & Mod5 & Mod6,
State1 | State2 | State3 | State4 | State5 | State6
>
export function and<Filters extends UpdateFilter<any, any>[]>(
...fns: Filters
): UpdateFilter<ExtractBaseMany<Filters>, UnionToIntersection<ExtractMod<Filters[number]>>>
/**
* Combine two filters by applying an AND logical operation:
* `and(fn1, fn2) = fn1 AND fn2`
* Combine multiple filters by applying an AND logical
* operation between every one of them:
* `and(fn1, fn2, ..., fnN) = fn1 AND fn2 AND ... AND fnN`
*
* > **Note**: This also combines type modifications, i.e.
* > if the 1st has modification `{ field1: string }`
@ -45,92 +125,14 @@ export function not<Base, Mod, State>(
* > 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.
* > **Note**: Due to TypeScript limitations (or more likely my lack of brain cells),
* > state type is only correctly inferred for up to 6 filters.
* > If you need more, either provide type explicitly (e.g. `filters.state<SomeState>(...)`),
* > or combine multiple `and` calls.
*
* @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])
export function and(...fns: UpdateFilter<any, any, any>[]): UpdateFilter<any, any, any> {
return (upd, state) => {
let i = 0
const max = fns.length
@ -157,25 +159,103 @@ export function every<Filters extends UpdateFilter<any, any>[]>(
}
}
export function or<Base1, Mod1, State1, Base2, Mod2, State2>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
): UpdateFilter<Base1 & Base2, Mod1 | Mod2, State1 | State2>
export function or<Base1, Mod1, State1, Base2, Mod2, State2, Base3, Mod3, State3>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
fn3: UpdateFilter<Base3, Mod3, State3>,
): UpdateFilter<Base1 & Base2 & Base3, Mod1 | Mod2 | Mod3, State1 | State2 | State3>
export function or<Base1, Mod1, State1, Base2, Mod2, State2, Base3, Mod3, State3, Base4, Mod4, State4>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
fn3: UpdateFilter<Base3, Mod3, State3>,
fn4: UpdateFilter<Base4, Mod4, State4>,
): UpdateFilter<Base1 & Base2 & Base3 & Base4, Mod1 | Mod2 | Mod3 | Mod4, State1 | State2 | State3 | State4>
export function or<
Base1,
Mod1,
State1,
Base2,
Mod2,
State2,
Base3,
Mod3,
State3,
Base4,
Mod4,
State4,
Base5,
Mod5,
State5,
>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
fn3: UpdateFilter<Base3, Mod3, State3>,
fn4: UpdateFilter<Base4, Mod4, State4>,
fn5: UpdateFilter<Base5, Mod5, State5>,
): UpdateFilter<
Base1 & Base2 & Base3 & Base4 & Base5,
Mod1 | Mod2 | Mod3 | Mod4 | Mod5,
State1 | State2 | State3 | State4 | State5
>
export function or<
Base1,
Mod1,
State1,
Base2,
Mod2,
State2,
Base3,
Mod3,
State3,
Base4,
Mod4,
State4,
Base5,
Mod5,
State5,
Base6,
Mod6,
State6,
>(
fn1: UpdateFilter<Base1, Mod1, State1>,
fn2: UpdateFilter<Base2, Mod2, State2>,
fn3: UpdateFilter<Base3, Mod3, State3>,
fn4: UpdateFilter<Base4, Mod4, State4>,
fn5: UpdateFilter<Base5, Mod5, State5>,
fn6: UpdateFilter<Base6, Mod6, State6>,
): UpdateFilter<
Base1 & Base2 & Base3 & Base4 & Base5 & Base6,
Mod1 | Mod2 | Mod3 | Mod4 | Mod5 | Mod6,
State1 | State2 | State3 | State4 | State5 | State6
>
/**
* Combine multiple filters by applying an OR logical
* operation between every one of them:
* `every(fn1, fn2, ..., fnN) = fn1 OR fn2 OR ... OR fnN`
* `or(fn1, fn2, ..., fnN) = fn1 OR fn2 OR ... OR fnN`
*
* > **Note**: This also combines type modification in a way
* > similar to {@link or}.
* >
* > This method is less efficient than {@link or}
* > **Note**: This also combines type modifications in a union, i.e.
* > if the 1st has modification `{ field1: string }`
* > and the 2nd has modification `{ field2: number }`,
* > then the combined filter will have
* > modification `{ field1: string } | { field2: number }`.
*
* > **Note**: This method *currently* does not propagate state
* > type. This might be fixed in the future, but for now either
* > use {@link or} or add type manually.
* > **Note**: Due to TypeScript limitations (or more likely my lack of brain cells),
* > state type is only correctly inferred for up to 6 filters.
* > If you need more, either provide type explicitly (e.g. `filters.state<SomeState>(...)`),
* > or combine multiple `and` calls.
*
* @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]>> {
export function or(...fns: UpdateFilter<any, any, any>[]): UpdateFilter<any, any, any> {
if (fns.length === 2) return or(fns[0], fns[1])
return (upd, state) => {
@ -203,3 +283,79 @@ export function some<Filters extends UpdateFilter<any, any, any>[]>(
return next()
}
}
/**
* For updates that contain an array of updates (e.g. `message_group`),
* apply a filter to every element of the array.
*
* Filter will match if **all** elements match.
*
* > **Note**: This also applies type modification to every element of the array.
*
* @param filter
* @returns
*/
export function every<Base, Mod, State>(filter: UpdateFilter<Base, Mod, State>): UpdateFilter<Base[], Mod, State> {
return (upds, state) => {
let i = 0
const max = upds.length
const next = (): MaybeAsync<boolean> => {
if (i === max) return true
const res = filter(upds[i++], state)
if (typeof res === 'boolean') {
if (!res) return false
return next()
}
return res.then((r: boolean) => {
if (!r) return false
return next()
})
}
return next()
}
}
/**
* For updates that contain an array of updates (e.g. `message_group`),
* apply a filter to every element of the array.
*
* Filter will match if **all** elements match.
*
* > **Note**: This *does not* apply type modification to any element of the array
*
* @param filter
* @returns
*/
export function some<Base, Mod, State>(filter: UpdateFilter<Base, Mod, State>): UpdateFilter<Base[], Mod, State> {
return (upds, state) => {
let i = 0
const max = upds.length
const next = (): MaybeAsync<boolean> => {
if (i === max) return false
const res = filter(upds[i++], state)
if (typeof res === 'boolean') {
if (res) return true
return next()
}
return res.then((r: boolean) => {
if (r) return true
return next()
})
}
return next()
}
}

View file

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

View file

@ -79,7 +79,7 @@ export type UpdateFilter<Base, Mod = {}, State = never> = (
state?: UpdateState<State>,
) => MaybeAsync<boolean>
export type Modify<Base, Mod> = Omit<Base, keyof Mod> & Mod
export type Modify<Base, Mod> = Base extends (infer T)[] ? Modify<T, Mod>[] : Omit<Base, keyof Mod> & Mod
export type Invert<Base, Mod> = {
[P in keyof Mod & keyof Base]: Exclude<Base[P], Mod[P]>
}

View file

@ -50,6 +50,7 @@ export type RawUpdateHandler = BaseUpdateHandler<
// begin-codegen
export type NewMessageHandler<T = Message, S = never> = ParsedUpdateHandler<'new_message', T, S>
export type EditMessageHandler<T = Message, S = never> = ParsedUpdateHandler<'edit_message', T, S>
export type MessageGroupHandler<T = Message[], S = never> = ParsedUpdateHandler<'message_group', T, S>
export type DeleteMessageHandler<T = DeleteMessageUpdate> = ParsedUpdateHandler<'delete_message', T>
export type ChatMemberUpdateHandler<T = ChatMemberUpdate> = ParsedUpdateHandler<'chat_member', T>
export type InlineQueryHandler<T = InlineQuery> = ParsedUpdateHandler<'inline_query', T>
@ -71,6 +72,7 @@ export type UpdateHandler =
| RawUpdateHandler
| NewMessageHandler
| EditMessageHandler
| MessageGroupHandler
| DeleteMessageHandler
| ChatMemberUpdateHandler
| InlineQueryHandler