Skip to main content
Filter queries are a concise syntax for specifying which updates your middleware should handle. They let you write bot.on('message:text') instead of checking if (ctx.message?.text).

What are Filter Queries?

A filter query is a string that describes which updates to match. Filter queries work with bot.on() and automatically narrow the TypeScript types.
// Without filter queries
bot.use((ctx) => {
  if (ctx.message && ctx.message.text) {
    const text: string | undefined = ctx.message.text // Still possibly undefined!
  }
})

// With filter queries
bot.on('message:text', (ctx) => {
  const text: string = ctx.message.text // Guaranteed to be string!
})

Filter Query Syntax

Filter queries have three levels:
L1:L2:L3
  • L1: Update type (message, callback_query, etc.)
  • L2: Property on the update type (text, photo, etc.)
  • L3: Nested property (entities, specific entity types, etc.)

Level 1 - Update Types

// Match specific update types
bot.on('message', ctx => { /* New messages */ })
bot.on('edited_message', ctx => { /* Edited messages */ })
bot.on('callback_query', ctx => { /* Button clicks */ })
bot.on('inline_query', ctx => { /* Inline queries */ })
bot.on('message_reaction', ctx => { /* Reactions */ })
Available L1 filters:
  • message - New messages
  • edited_message - Edited messages
  • channel_post - New channel posts
  • edited_channel_post - Edited channel posts
  • business_connection - Business connection updates
  • business_message - Business account messages
  • edited_business_message - Edited business messages
  • deleted_business_messages - Deleted business messages
  • inline_query - Inline queries
  • chosen_inline_result - Chosen inline results
  • callback_query - Callback queries
  • shipping_query - Shipping queries
  • pre_checkout_query - Pre-checkout queries
  • poll - Poll updates
  • poll_answer - Poll answers
  • my_chat_member - Bot’s chat member status changed
  • chat_member - Chat member status changed
  • chat_join_request - Join requests
  • message_reaction - Message reactions
  • message_reaction_count - Reaction counts
  • chat_boost - Chat boosts
  • removed_chat_boost - Removed boosts
  • purchased_paid_media - Paid media purchases

Level 2 - Message Properties

// Match messages with specific content
bot.on('message:text', ctx => { /* Text messages */ })
bot.on('message:photo', ctx => { /* Photos */ })
bot.on('message:video', ctx => { /* Videos */ })
bot.on('message:document', ctx => { /* Documents */ })
bot.on('message:sticker', ctx => { /* Stickers */ })
Common L2 filters:
  • :text - Text messages
  • :photo - Photos
  • :video - Videos
  • :audio - Audio files
  • :document - Documents
  • :animation - Animations/GIFs
  • :voice - Voice messages
  • :video_note - Video notes
  • :sticker - Stickers
  • :location - Locations
  • :contact - Contacts
  • :poll - Polls
  • :dice - Dice
  • :game - Games
  • :invoice - Invoices

Level 3 - Nested Properties

// Match messages with specific entities
bot.on('message:entities:url', ctx => {
  // Messages with URL entities
  const urls = ctx.entities('url')
})

bot.on('message:entities:mention', ctx => {
  // Messages with @mentions
})

bot.on('message:entities:hashtag', ctx => {
  // Messages with #hashtags
})
Entity types (L3 for :entities or :caption_entities):
  • :mention - @username mentions
  • :hashtag - #hashtag
  • :cashtag - $USD
  • :bot_command - /command
  • :url - https://example.com
  • :email - email@example.com
  • :phone_number - Phone numbers
  • :bold - Bold text
  • :italic - Italic text
  • :underline - Underlined text
  • :strikethrough - Strikethrough text
  • :spoiler - Spoiler text
  • :code - Inline code
  • :pre - Code blocks
  • :text_link - Text links
  • :text_mention - Text mentions
  • :custom_emoji - Custom emoji

Shortcuts

Filter queries support powerful shortcuts:

Empty L1 - Messages and Channel Posts

: matches both message and channel_post:
// Matches both messages and channel posts with text
bot.on(':text', ctx => {
  // ctx.message?.text OR ctx.channelPost?.text
  const text = ctx.msg?.text
})
Equivalent to:
bot.on(['message:text', 'channel_post:text'], ctx => { /* ... */ })

msg - All Message Types

msg: matches all message-like updates:
bot.on('msg:text', ctx => {
  // Matches:
  // - message:text
  // - channel_post:text  
  // - edited_message:text
  // - edited_channel_post:text
  // - business_message:text
  // - edited_business_message:text
})

edit - Edited Messages

edit: matches edited messages and channel posts:
bot.on('edit:text', ctx => {
  // Matches:
  // - edited_message:text
  // - edited_channel_post:text
})

Double Colon - Entity Shortcuts

:: expands to both :entities: and :caption_entities::
// Match URL in text OR caption
bot.on('message::url', ctx => {
  // Matches:
  // - message:entities:url
  // - message:caption_entities:url
})

// In any message-like update
bot.on('::url', ctx => {
  // URLs in text or caption of any message type
})

media - Photo or Video

bot.on(':media', ctx => {
  // Matches:
  // - :photo
  // - :video
})

file - Any File Type

bot.on(':file', ctx => {
  // Matches:
  // - :photo
  // - :animation
  // - :audio
  // - :document
  // - :video
  // - :video_note
  // - :voice
  // - :sticker
})

Combining Filters

OR Logic - Array of Filters

Pass an array to match any of the filters:
// Match text messages OR photos
bot.on(['message:text', 'message:photo'], ctx => {
  if (ctx.message.text) {
    console.log('Text:', ctx.message.text)
  } else if (ctx.message.photo) {
    console.log('Photo')
  }
})

// Using shortcuts
bot.on([':text', ':photo'], ctx => { /* ... */ })

AND Logic - Chaining

Chain .on() calls for AND logic:
// Messages that are BOTH text AND contain URLs
bot.on(':text').on('::url', ctx => {
  // ctx.msg.text exists AND has URL entities
  const text: string = ctx.msg.text
})

// Private chat text messages
bot.chatType('private').on('message:text', ctx => {
  // Private chat AND text message
})

Advanced Examples

Forwarded Messages

// Any forwarded message
bot.on(':forward_origin', ctx => {
  const origin = ctx.msg?.forward_origin
})

// Forwarded from users
bot.on(':forward_origin:user', ctx => {
  // origin.type === 'user'
})

// Forwarded from channels
bot.on(':forward_origin:channel', ctx => {
  // origin.type === 'channel'
})

Service Messages

// New chat members
bot.on('message:new_chat_members', ctx => {
  const newMembers = ctx.message.new_chat_members
})

// Chat title changed
bot.on(':new_chat_title', ctx => {
  console.log('New title:', ctx.msg.new_chat_title)
})

// Pinned message
bot.on(':pinned_message', ctx => {
  const pinned = ctx.msg.pinned_message
})

Bot-Specific Filters

The special me filter matches your bot:
// New members that include your bot
bot.on('message:new_chat_members:me', ctx => {
  console.log('Bot was added to the chat!')
})

// Someone left, and it was the bot
bot.on('message:left_chat_member:me', ctx => {
  console.log('Bot was removed')
})

Business Updates

// Business connection enabled
bot.on('business_connection:is_enabled', ctx => {
  console.log('Business connected')
})

// Business messages
bot.on('business_message:text', ctx => {
  const text = ctx.businessMessage.text
})

Reactions

// Any emoji reaction
bot.on('message_reaction:new_reaction:emoji', ctx => {
  const reactions = ctx.messageReaction.new_reaction
})

// Paid reactions
bot.on('message_reaction:new_reaction:paid', ctx => {
  console.log('Paid reaction received!')
})

Stickers

// Animated stickers
bot.on('message:sticker:is_animated', ctx => {
  console.log('Animated sticker')
})

// Video stickers
bot.on('message:sticker:is_video', ctx => {
  console.log('Video sticker')
})

// Premium stickers
bot.on('message:sticker:premium_animation', ctx => {
  console.log('Premium sticker')
})

Using Filter Functions

Create reusable filter predicates:
import { matchFilter } from 'grammy'

// Create a filter function
const hasPhoto = matchFilter('message:photo')

// Use it
bot.filter(hasPhoto, ctx => {
  // ctx.message.photo is guaranteed
})

// Or use the predicate directly
bot.use((ctx) => {
  if (hasPhoto(ctx)) {
    // Type is narrowed here too!
    console.log(ctx.message.photo)
  }
})
Drop updates that match:
import { matchFilter } from 'grammy'

// Ignore forwarded messages
bot.drop(matchFilter(':forward_origin'), ctx => {
  // Only non-forwarded messages reach here
})

Type Safety

Filter queries provide automatic type narrowing:
bot.on('message:text', ctx => {
  // TypeScript knows these are defined:
  ctx.message        // Message (not undefined)
  ctx.message.text   // string (not undefined)
  ctx.msg            // Message (not undefined)
  ctx.msg.text       // string (not undefined)
})

bot.on('message:photo', ctx => {
  ctx.message.photo  // PhotoSize[] (not undefined)
})

bot.on('callback_query:data', ctx => {
  ctx.callbackQuery.data  // string (not undefined)
})
Multiple filters create union types:
bot.on(['message:text', 'message:photo'], ctx => {
  // ctx.message.text may or may not exist
  // ctx.message.photo may or may not exist
  // Use type guards:
  if (ctx.message.text) {
    const text: string = ctx.message.text
  } else if (ctx.message.photo) {
    const photos: PhotoSize[] = ctx.message.photo
  }
})

Performance

Filter queries are compiled to efficient predicate functions:
// This filter query:
bot.on('message:text')

// Compiles to roughly:
function predicate(ctx) {
  return ctx.update.message?.text !== undefined
}

// And is cached for reuse
The compilation happens once, so there’s no performance penalty for using filter queries.

Common Patterns

Ignore Bot Messages

bot.use((ctx, next) => {
  if (ctx.from?.is_bot) return // Ignore bots
  return next()
})

Admin-Only Commands

const ADMIN_IDS = [123456789, 987654321]

bot.filter(
  ctx => ctx.from && ADMIN_IDS.includes(ctx.from.id),
  (ctx) => {
    // Admin-only handlers
  }
)

Private Chat Only

bot.chatType('private').command('secret', ctx => {
  ctx.reply('This only works in private chats')
})

Group Features

bot.chatType(['group', 'supergroup'], ctx => {
  // Group-only features
})

Debugging Filter Queries

If a filter query isn’t working as expected:
import { parse, preprocess } from 'grammy'

// See how a query expands
const query = '::url'
const parsed = parse(query)
const expanded = parsed.flatMap(preprocess)
console.log(expanded)
// Outputs all the expanded filter combinations

Best Practices

Use Specific FiltersBe as specific as possible:
// Less specific
bot.on('message', ctx => {
  if (ctx.message.text) { /* ... */ }
})

// More specific (better)
bot.on('message:text', ctx => {
  // Automatic type narrowing!
})
Combine Filters for Precision
// Private chat text messages with URLs
bot
  .chatType('private')
  .on('message:text')
  .filter(ctx => ctx.entities('url').length > 0)
  .use(ctx => {
    // Very specific handler
  })
Use Shortcuts for Readability
// Instead of:
bot.on([
  'message:entities:url',
  'message:caption_entities:url'
], ctx => { /* ... */ })

// Use:
bot.on('message::url', ctx => { /* ... */ })

Build docs developers (and LLMs) love