/** @format */ import * as tg from './core/types/typegram' import * as tt from './telegram-types' import { Middleware, MiddlewareFn, MiddlewareObj } from './middleware' import Context from './context' export type MaybeArray = T | T[] export type MaybePromise = T | Promise export type NonemptyReadonlyArray = readonly [T, ...T[]] export type Triggers = MaybeArray< string | RegExp | ((value: string, ctx: C) => RegExpExecArray | null) > export type Predicate = (t: T) => boolean export type AsyncPredicate = (t: T) => Promise export type MatchedMiddleware< C extends Context, T extends tt.UpdateType | tt.MessageSubType = 'message' | 'channel_post' > = NonemptyReadonlyArray< Middleware> > /** Takes: a context type and an update type (or message subtype). Produces: a context that has some properties required, and some undefined. The required ones are those that are always present when the given update (or message) arrives. The undefined ones are those that are always absent when the given update (or message) arrives. */ /** @deprecated */ type MatchedContext< C extends Context, T extends tt.UpdateType | tt.MessageSubType > = NarrowedContext /** * Narrows down `C['update']` (and derived getters) * to specific update type `U`. * * Used by [[`Composer`]], * possibly useful for splitting a bot into multiple files. */ export type NarrowedContext< C extends Context, U extends tg.Update > = Context & Omit export interface GameQueryUpdate extends tg.Update.CallbackQueryUpdate { callback_query: tg.CallbackQuery.GameShortGameCallbackQuery } function always(x: T) { return () => x } const anoop = always(Promise.resolve()) export class Composer implements MiddlewareObj { private handler: MiddlewareFn constructor(...fns: ReadonlyArray>) { this.handler = Composer.compose(fns) } /** * Registers a middleware. */ use(...fns: ReadonlyArray>) { this.handler = Composer.compose([this.handler, ...fns]) return this } /** * Registers middleware for handling updates * matching given type guard function. */ guard( guardFn: (update: tg.Update) => update is U, ...fns: NonemptyReadonlyArray>> ) { return this.use(Composer.guard(guardFn, ...fns)) } /** * Registers middleware for handling provided update types. */ on( updateType: MaybeArray, ...fns: NonemptyReadonlyArray>> ) { return this.use(Composer.mount(updateType, ...fns)) } /** * Registers middleware for handling matching text messages. */ hears(triggers: Triggers, ...fns: MatchedMiddleware) { return this.use(Composer.hears(triggers, ...fns)) } /** * Registers middleware for handling specified commands. */ command( command: MaybeArray, ...fns: NonemptyReadonlyArray>> ) { return this.use(Composer.command(command, ...fns)) } /** * Registers middleware for handling matching callback queries. */ action( triggers: Triggers, ...fns: MatchedMiddleware ) { return this.use(Composer.action(triggers, ...fns)) } /** * Registers middleware for handling matching inline queries. */ inlineQuery( triggers: Triggers, ...fns: MatchedMiddleware ) { return this.use(Composer.inlineQuery(triggers, ...fns)) } /** * Registers middleware for handling game queries */ gameQuery( ...fns: NonemptyReadonlyArray< Middleware> > ) { return this.use(Composer.gameQuery(...fns)) } /** * Registers middleware for dropping matching updates. */ drop(predicate: Predicate) { return this.use(Composer.drop(predicate)) } filter(predicate: Predicate) { return this.use(Composer.filter(predicate)) } private entity< T extends 'message' | 'channel_post' | tt.MessageSubType = | 'message' | 'channel_post' >( predicate: | MaybeArray | ((entity: tg.MessageEntity, s: string, ctx: C) => boolean), ...fns: ReadonlyArray>> ) { return this.use(Composer.entity(predicate, ...fns)) } email(email: Triggers, ...fns: MatchedMiddleware) { return this.use(Composer.email(email, ...fns)) } url(url: Triggers, ...fns: MatchedMiddleware) { return this.use(Composer.url(url, ...fns)) } textLink(link: Triggers, ...fns: MatchedMiddleware) { return this.use(Composer.textLink(link, ...fns)) } textMention(mention: Triggers, ...fns: MatchedMiddleware) { return this.use(Composer.textMention(mention, ...fns)) } mention(mention: MaybeArray, ...fns: MatchedMiddleware) { return this.use(Composer.mention(mention, ...fns)) } phone(number: Triggers, ...fns: MatchedMiddleware) { return this.use(Composer.phone(number, ...fns)) } hashtag(hashtag: MaybeArray, ...fns: MatchedMiddleware) { return this.use(Composer.hashtag(hashtag, ...fns)) } cashtag(cashtag: MaybeArray, ...fns: MatchedMiddleware) { return this.use(Composer.cashtag(cashtag, ...fns)) } /** * Registers a middleware for handling /start */ start( ...fns: NonemptyReadonlyArray< Middleware & { startPayload: string }> > ) { const handler = Composer.compose(fns) return this.command('start', (ctx, next) => { const entity = ctx.message.entities![0]! const startPayload = ctx.message.text.slice(entity.length + 1) return handler(Object.assign(ctx, { startPayload }), next) }) } /** * Registers a middleware for handling /help */ help(...fns: NonemptyReadonlyArray>>) { return this.command('help', ...fns) } /** * Registers a middleware for handling /settings */ settings( ...fns: NonemptyReadonlyArray>> ) { return this.command('settings', ...fns) } middleware() { return this.handler } static reply(...args: Parameters): MiddlewareFn { return (ctx) => ctx.reply(...args) } static catch( errorHandler: (err: unknown, ctx: C) => void, ...fns: ReadonlyArray> ): MiddlewareFn { const handler = Composer.compose(fns) // prettier-ignore return (ctx, next) => Promise.resolve(handler(ctx, next)) .catch((err) => errorHandler(err, ctx)) } /** * Generates middleware that runs in the background. */ static fork(middleware: Middleware): MiddlewareFn { const handler = Composer.unwrap(middleware) return async (ctx, next) => { await Promise.all([handler(ctx, anoop), next()]) } } static tap(middleware: Middleware): MiddlewareFn { const handler = Composer.unwrap(middleware) return (ctx, next) => Promise.resolve(handler(ctx, anoop)).then(() => next()) } /** * Generates middleware that gives up control to the next middleware. */ static passThru(): MiddlewareFn { return (ctx, next) => next() } static lazy( factoryFn: (ctx: C) => MaybePromise> ): MiddlewareFn { if (typeof factoryFn !== 'function') { throw new Error('Argument must be a function') } return (ctx, next) => Promise.resolve(factoryFn(ctx)).then((middleware) => Composer.unwrap(middleware)(ctx, next) ) } static log(logFn: (s: string) => void = console.log): MiddlewareFn { return (ctx, next) => { logFn(JSON.stringify(ctx.update, null, 2)) return next() } } /** * @param trueMiddleware middleware to run if the predicate returns true * @param falseMiddleware middleware to run if the predicate returns false */ static branch( predicate: Predicate | AsyncPredicate, trueMiddleware: Middleware, falseMiddleware: Middleware ): MiddlewareFn { if (typeof predicate !== 'function') { return Composer.unwrap(predicate ? trueMiddleware : falseMiddleware) } return Composer.lazy((ctx) => Promise.resolve(predicate(ctx)).then((value) => value ? trueMiddleware : falseMiddleware ) ) } /** * Generates optional middleware. * @param predicate predicate to decide on a context object whether to run the middleware * @param middleware middleware to run if the predicate returns true */ static optional( predicate: Predicate | AsyncPredicate, ...fns: NonemptyReadonlyArray> ): MiddlewareFn { return Composer.branch( predicate, Composer.compose(fns), Composer.passThru() ) } static filter(predicate: Predicate): MiddlewareFn { return Composer.branch(predicate, Composer.passThru(), anoop) } /** * Generates middleware for dropping matching updates. */ static drop(predicate: Predicate): MiddlewareFn { return Composer.branch(predicate, anoop, Composer.passThru()) } static dispatch< C extends Context, Handlers extends Record> >( routeFn: (ctx: C) => MaybePromise, handlers: Handlers ): Middleware { return Composer.lazy((ctx) => Promise.resolve(routeFn(ctx)).then((value) => handlers[value]) ) } // EXPLANATION FOR THE ts-expect-error ANNOTATIONS // The annotations around function invocations with `...fns` are there // whenever we perform validation logic that the flow analysis of TypeScript // cannot comprehend. We always make sure that the middleware functions are // only invoked with properly constrained context objects, but this cannot be // determined automatically. /** * Generates optional middleware based on a predicate that only operates on `ctx.update`. * * Example: * ```ts * import { Composer, Update } from 'telegraf' * * const predicate = (u): u is Update.MessageUpdate => 'message' in u * const middleware = Composer.guard(predicate, (ctx) => { * const message = ctx.update.message * }) * ``` * * Note that `Composer.mount('message')` is preferred over this. * * @param guardFn predicate to decide whether to run the middleware based on the `ctx.update` object * @param fns middleware to run if the predicate returns true * @see `Composer.optional` for a more generic version of this method that allows the predicate to operate on `ctx` itself */ static guard( guardFn: (u: tg.Update) => u is U, ...fns: NonemptyReadonlyArray>> ): MiddlewareFn { return Composer.optional( (ctx) => guardFn(ctx.update), // @ts-expect-error see explanation above ...fns ) } /** * Generates middleware for handling provided update types. * @deprecated use `Composer.on` */ static mount( updateType: MaybeArray, ...fns: NonemptyReadonlyArray>> ): MiddlewareFn { return Composer.on(updateType, ...fns) } /** * Generates middleware for handling provided update types. */ static on( updateType: MaybeArray, ...fns: NonemptyReadonlyArray>> ): MiddlewareFn { const updateTypes = normalizeTextArguments(updateType) const predicate = ( update: tg.Update ): update is tg.Update & tt.MountMap[T] => updateTypes.some( (type) => // Check update type type in update || // Check message sub-type ('message' in update && type in update.message) ) return Composer.guard(predicate, ...fns) } private static entity< C extends Context, T extends 'message' | 'channel_post' | tt.MessageSubType = | 'message' | 'channel_post' >( predicate: | MaybeArray | ((entity: tg.MessageEntity, s: string, ctx: C) => boolean), ...fns: ReadonlyArray>> ): MiddlewareFn { if (typeof predicate !== 'function') { const entityTypes = normalizeTextArguments(predicate) return Composer.entity(({ type }) => entityTypes.includes(type), ...fns) } return Composer.optional((ctx) => { const msg: tg.Message | undefined = ctx.message ?? ctx.channelPost if (msg === undefined) { return false } const text = getText(msg) const entities = getEntities(msg) if (text === undefined) return false return entities.some((entity) => predicate( entity, text.substring(entity.offset, entity.offset + entity.length), ctx ) ) // @ts-expect-error see explanation above }, ...fns) } static entityText( entityType: MaybeArray, predicate: Triggers, ...fns: MatchedMiddleware ): MiddlewareFn { if (fns.length === 0) { // prettier-ignore return Array.isArray(predicate) // @ts-expect-error predicate is really the middleware ? Composer.entity(entityType, ...predicate) // @ts-expect-error predicate is really the middleware : Composer.entity(entityType, predicate) } const triggers = normalizeTriggers(predicate) return Composer.entity(({ type }, value, ctx) => { if (type !== entityType) { return false } for (const trigger of triggers) { // @ts-expect-error define so far unknown property `match` if ((ctx.match = trigger(value, ctx))) { return true } } return false // @ts-expect-error see explanation above }, ...fns) } static email( email: Triggers, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.entityText('email', email, ...fns) } static phone( number: Triggers, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.entityText('phone_number', number, ...fns) } static url( url: Triggers, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.entityText('url', url, ...fns) } static textLink( link: Triggers, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.entityText('text_link', link, ...fns) } static textMention( mention: Triggers, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.entityText('text_mention', mention, ...fns) } static mention( mention: MaybeArray, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.entityText( 'mention', normalizeTextArguments(mention, '@'), ...fns ) } static hashtag( hashtag: MaybeArray, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.entityText( 'hashtag', normalizeTextArguments(hashtag, '#'), ...fns ) } static cashtag( cashtag: MaybeArray, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.entityText( 'cashtag', normalizeTextArguments(cashtag, '$'), ...fns ) } private static match< C extends Context, T extends | 'message' | 'channel_post' | 'callback_query' | 'inline_query' | tt.MessageSubType >( triggers: ReadonlyArray<(text: string, ctx: C) => RegExpExecArray | null>, ...fns: MatchedMiddleware ): MiddlewareFn> { const handler = Composer.compose(fns) return (ctx, next) => { const text = getText(ctx.message) ?? getText(ctx.channelPost) ?? getText(ctx.callbackQuery) ?? ctx.inlineQuery?.query if (text === undefined) return next() for (const trigger of triggers) { // @ts-expect-error const match = trigger(text, ctx) if (match) { // @ts-expect-error define so far unknown property `match` return handler(Object.assign(ctx, { match }), next) } } return next() } } /** * Generates middleware for handling matching text messages. */ static hears( triggers: Triggers, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.mount( 'text', Composer.match(normalizeTriggers(triggers), ...fns) ) } /** * Generates middleware for handling specified commands. */ static command( command: MaybeArray, ...fns: NonemptyReadonlyArray>> ): MiddlewareFn { if (fns.length === 0) { // @ts-expect-error command is really the middleware return Composer.entity('bot_command', command) } const commands = normalizeTextArguments(command, '/') return Composer.mount( 'text', Composer.lazy>((ctx) => { const groupCommands = ctx.me && ctx.chat?.type.endsWith('group') ? commands.map((command) => `${command}@${ctx.me}`) : [] return Composer.entity>( ({ offset, type }, value) => offset === 0 && type === 'bot_command' && (commands.includes(value) || groupCommands.includes(value)), // @ts-expect-error see explanation above ...fns ) }) ) } /** * Generates middleware for handling matching callback queries. */ static action( triggers: Triggers, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.mount( 'callback_query', Composer.match(normalizeTriggers(triggers), ...fns) ) } /** * Generates middleware for handling matching inline queries. */ static inlineQuery( triggers: Triggers, ...fns: MatchedMiddleware ): MiddlewareFn { return Composer.mount( 'inline_query', Composer.match(normalizeTriggers(triggers), ...fns) ) } /** * Generates middleware responding only to specified users. */ static acl( userId: MaybeArray, ...fns: NonemptyReadonlyArray> ): MiddlewareFn { if (typeof userId === 'function') { return Composer.optional(userId, ...fns) } const allowed = Array.isArray(userId) ? userId : [userId] // prettier-ignore return Composer.optional((ctx) => !ctx.from || allowed.includes(ctx.from.id), ...fns) } private static memberStatus( status: MaybeArray, ...fns: NonemptyReadonlyArray> ): MiddlewareFn { const statuses = Array.isArray(status) ? status : [status] return Composer.optional(async (ctx) => { if (ctx.message === undefined) return false const member = await ctx.getChatMember(ctx.message.from.id) return statuses.includes(member.status) }, ...fns) } /** * Generates middleware responding only to chat admins and chat creator. */ static admin( ...fns: NonemptyReadonlyArray> ): MiddlewareFn { return Composer.memberStatus(['administrator', 'creator'], ...fns) } /** * Generates middleware responding only to chat creator. */ static creator( ...fns: NonemptyReadonlyArray> ): MiddlewareFn { return Composer.memberStatus('creator', ...fns) } /** * Generates middleware running only in specified chat types. */ static chatType( type: MaybeArray, ...fns: NonemptyReadonlyArray> ): MiddlewareFn { const types = Array.isArray(type) ? type : [type] return Composer.optional((ctx) => { const chat = ctx.chat return chat !== undefined && types.includes(chat.type) }, ...fns) } /** * Generates middleware running only in private chats. */ static privateChat( ...fns: NonemptyReadonlyArray> ): MiddlewareFn { return Composer.chatType('private', ...fns) } /** * Generates middleware running only in groups and supergroups. */ static groupChat( ...fns: NonemptyReadonlyArray> ): MiddlewareFn { return Composer.chatType(['group', 'supergroup'], ...fns) } /** * Generates middleware for handling game queries. */ static gameQuery( ...fns: NonemptyReadonlyArray< Middleware> > ): MiddlewareFn { return Composer.guard( (u): u is GameQueryUpdate => 'callback_query' in u && 'game_short_name' in u.callback_query, ...fns ) } static unwrap(handler: Middleware): MiddlewareFn { if (!handler) { throw new Error('Handler is undefined') } return 'middleware' in handler ? handler.middleware() : handler } static compose( middlewares: ReadonlyArray> ): MiddlewareFn { if (!Array.isArray(middlewares)) { throw new Error('Middlewares must be an array') } if (middlewares.length === 0) { return Composer.passThru() } if (middlewares.length === 1) { return Composer.unwrap(middlewares[0]!) } return (ctx, next) => { let index = -1 return execute(0, ctx) async function execute(i: number, context: C): Promise { if (!(context instanceof Context)) { throw new Error('next(ctx) called with invalid context') } if (i <= index) { throw new Error('next() called multiple times') } index = i const handler = Composer.unwrap(middlewares[i] ?? next) await handler(context, async (ctx = context) => { await execute(i + 1, ctx) }) } } } } function escapeRegExp(s: string) { // $& means the whole matched string return s.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&') } function normalizeTriggers( triggers: Triggers ): Array<(value: string, ctx: C) => RegExpExecArray | null> { if (!Array.isArray(triggers)) { triggers = [triggers] } return triggers.map((trigger) => { if (!trigger) { throw new Error('Invalid trigger') } if (typeof trigger === 'function') { return trigger } if (trigger instanceof RegExp) { return (value = '') => { trigger.lastIndex = 0 return trigger.exec(value) } } const regex = new RegExp(`^${escapeRegExp(trigger)}$`) return (value: string) => regex.exec(value) }) } function getEntities(msg: tg.Message | undefined): tg.MessageEntity[] { if (msg == null) return [] if ('caption_entities' in msg) return msg.caption_entities ?? [] if ('entities' in msg) return msg.entities ?? [] return [] } function getText( msg: tg.Message | tg.CallbackQuery | undefined ): string | undefined { if (msg == null) return undefined if ('caption' in msg) return msg.caption if ('text' in msg) return msg.text if ('data' in msg) return msg.data if ('game_short_name' in msg) return msg.game_short_name return undefined } function normalizeTextArguments(argument: MaybeArray, prefix = '') { const args = Array.isArray(argument) ? argument : [argument] // prettier-ignore return args .filter(Boolean) .map((arg) => prefix && typeof arg === 'string' && !arg.startsWith(prefix) ? `${prefix}${arg}` : arg) } export default Composer