PAYATHON 2026

Telegram bot 使用 Finite State Machine 时无法等待用户回复

支付老李

结论

问题不是 FSM “不会等待”,而是消息处理器的匹配条件和状态转换时机有误。

pyTelegramBotAPI 只会在收到消息时调用 handler。要实现逐步问答,流程应当是:

  1. 发送当前问题;
  2. 将 FSM 切换到等待该问题答案的状态;
  3. 结束当前 handler;
  4. 用户发送新消息后,根据当前状态处理答案;
  5. 保存答案并切换状态,然后发送下一个问题。

原代码中的 @bot.message_handler(content_types=['text']) 几乎会匹配所有文本消息。它与下面的状态过滤器叠加后,并不会形成”同时满足两个条件”的关系,而是将同一个函数注册为两个独立的 handler,因此可能绕过状态判断。

另外,第一个处理器发送尺寸问题后调用了:

fsm.asked_for_payment_method(message.text)

但此时应该进入等待尺寸答案的状态,而不是保存付款方式。

重新定义状态

状态名称可以直接描述机器人正在等待的内容:

from transitions import Machine


class TelegramBotFSM:
    states = [
        'idle',
        'waiting_size',
        'waiting_payment',
        'waiting_flavour',
        'waiting_confirmation',
    ]

    transitions = [
        {
            'trigger': 'start_order',
            'source': 'idle',
            'dest': 'waiting_size',
        },
        {
            'trigger': 'select_size',
            'source': 'waiting_size',
            'dest': 'waiting_payment',
            'before': 'update_size',
        },
        {
            'trigger': 'select_payment',
            'source': 'waiting_payment',
            'dest': 'waiting_flavour',
            'before': 'update_payment',
        },
        {
            'trigger': 'select_flavour',
            'source': 'waiting_flavour',
            'dest': 'waiting_confirmation',
            'before': 'update_flavour',
        },
        {
            'trigger': 'finish',
            'source': 'waiting_confirmation',
            'dest': 'idle',
            'after': 'clear_order',
        },
    ]

    def __init__(self):
        self.size = ''
        self.pay_method = ''
        self.flavour = ''

        self.machine = Machine(
            model=self,
            states=self.states,
            transitions=self.transitions,
            initial='idle',
        )

    def update_size(self, value):
        self.size = value

    def update_payment(self, value):
        self.pay_method = value

    def update_flavour(self, value):
        self.flavour = value

    def clear_order(self):
        self.size = ''
        self.pay_method = ''
        self.flavour = ''

Machine 中引用的类名必须与实际定义一致。原示例定义的是 telegram_bot_state,初始化时却引用了 TelegramBot.states。如果没有其他相关代码,这里会触发 NameError。

使用一个文本入口按状态分发

这种线性流程更适合使用一个文本 handler,再根据状态分发消息。这样也不会受到多个 handler 匹配顺序的影响:

import telebot
import config

from telebot import types
from FSM import TelegramBotFSM


bot = telebot.TeleBot(config.TOKEN)
fsm = TelegramBotFSM()


@bot.message_handler(commands=['start'])
def welcome(message):
    markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    markup.add(
        types.KeyboardButton("Заказать пиццу"),
        types.KeyboardButton("Посмотреть меню"),
    )

    bot.send_message(
        message.chat.id,
        (
            f"Добро пожаловать, {message.from_user.first_name}!\n"
            "Я помогу вам сделать заказ."
        ),
        reply_markup=markup,
    )


@bot.message_handler(content_types=['text'])
def handle_text(message):
    text = message.text.strip()

    if fsm.state == 'idle':
        if text != 'Заказать пиццу':
            bot.send_message(
                message.chat.id,
                'Чтобы начать заказ, нажмите «Заказать пиццу».',
            )
            return

        fsm.start_order()
        bot.send_message(
            message.chat.id,
            'Какую вы хотите пиццу? Большую или маленькую?',
        )
        return

    if fsm.state == 'waiting_size':
        fsm.select_size(text)
        bot.send_message(
            message.chat.id,
            'Как вы будете платить?',
        )
        return

    if fsm.state == 'waiting_payment':
        fsm.select_payment(text)
        bot.send_message(
            message.chat.id,
            'Какой вкус вы хотите?',
        )
        return

    if fsm.state == 'waiting_flavour':
        fsm.select_flavour(text)

        markup = types.InlineKeyboardMarkup(row_width=2)
        markup.add(
            types.InlineKeyboardButton("Да", callback_data='order_yes'),
            types.InlineKeyboardButton("Нет", callback_data='order_no'),
        )

        bot.send_message(
            message.chat.id,
            (
                f'Вы хотите пиццу размера «{fsm.size}», '
                f'вкус: «{fsm.flavour}», '
                f'способ оплаты: «{fsm.pay_method}». Всё верно?'
            ),
            reply_markup=markup,
        )
        return

每个分支末尾都要使用 return,明确结束当前消息的处理。后续步骤只能由用户发送的新消息触发。

处理确认按钮

确认按钮只应在 waiting_confirmation 状态下生效:

@bot.callback_query_handler(
    func=lambda call: call.data in ('order_yes', 'order_no')
)
def handle_confirmation(call):
    if fsm.state != 'waiting_confirmation':
        bot.answer_callback_query(
            call.id,
            text='这个订单已经失效,请重新下单。',
        )
        return

    summary = (
        f'Пицца: {fsm.size}, '
        f'вкус: {fsm.flavour}, '
        f'оплата: {fsm.pay_method}'
    )

    if call.data == 'order_yes':
        bot.edit_message_text(
            summary,
            chat_id=call.message.chat.id,
            message_id=call.message.message_id,
        )
        bot.send_message(
            call.message.chat.id,
            'Спасибо за заказ',
        )
        bot.answer_callback_query(
            call.id,
            text='Заказ оформлен',
            show_alert=True,
        )
    else:
        bot.edit_message_text(
            summary,
            chat_id=call.message.chat.id,
            message_id=call.message.message_id,
        )
        bot.send_message(
            call.message.chat.id,
            'Заказ отменён.',
        )
        bot.answer_callback_query(call.id)

    fsm.finish()


bot.infinity_polling()

这里必须先调用 edit_message_text(),再调用 fsm.finish()。finish() 会清空订单数据,如果提前执行,编辑后的订单摘要中只会剩下空字符串。

如果继续使用多个 handler

使用多个 handler 也可以,但不要在状态处理器上再叠加一个无条件的 content_types=['text']:

@bot.message_handler(
    func=lambda message: (
        message.content_type == 'text'
        and fsm.state == 'waiting_size'
    )
)
def handle_size(message):
    fsm.select_size(message.text.strip())
    bot.send_message(message.chat.id, 'Как вы будете платить?')


@bot.message_handler(
    func=lambda message: (
        message.content_type == 'text'
        and fsm.state == 'waiting_payment'
    )
)
def handle_payment(message):
    fsm.select_payment(message.text.strip())
    bot.send_message(message.chat.id, 'Какой вкус вы хотите?')

不要这样写:

@bot.message_handler(content_types=['text'])
@bot.message_handler(func=lambda message: fsm.state == 'waiting_size')

这两个装饰器不会组成”文本类型并且状态正确”的匹配条件,而会分别注册一次 handler。仅 content_types=['text'] 就足以匹配所有文本消息。

多用户场景必须分别保存 FSM

上面的单个 fsm 对象只适合本地测试:

fsm = TelegramBotFSM()

在真实 bot 中,所有聊天都会共享这个对象。用户 A 选择尺寸后,用户 B 发来的消息可能被当成用户 A 的付款方式。

至少需要按 chat.id 分别保存状态:

from collections import defaultdict


sessions = defaultdict(TelegramBotFSM)


def get_fsm(message):
    return sessions[message.chat.id]

然后在 handler 中获取当前聊天对应的 FSM:

@bot.message_handler(content_types=['text'])
def handle_text(message):
    fsm = get_fsm(message)
    text = message.text.strip()

    if fsm.state == 'idle':
        # ...
        return

    if fsm.state == 'waiting_size':
        # ...
        return

处理回调按钮时,也要获取对应聊天的状态:

@bot.callback_query_handler(
    func=lambda call: call.data in ('order_yes', 'order_no')
)
def handle_confirmation(call):
    fsm = sessions[call.message.chat.id]
    # ...

如果同一个私聊用户可能同时从多个入口操作,可以使用 (chat.id, from_user.id) 作为会话键。生产环境还要考虑进程重启后的状态持久化。多进程部署时,则需要使用 Redis、数据库等共享存储。

原则很简单:发送问题时只切换到等待状态,然后结束 handler。等下一条消息到达后,再保存答案、执行状态转换并发送下一个问题。

备注:内容仅供参考。