PAYATHON 2026

如何在 Botman 对话中处理 Telegram 支付

支付阿杰

明确结论

BotMan 的 ask() 只能等待当前 Driver 能转换为 IncomingMessage 的更新。Telegram 的 pre_checkout_query 不属于普通消息,标准 Telegram Driver 通常不会把它传给当前 Conversation,因此仅靠 PaymentConversation 中的 ask() 无法直接监听。

建议按下面的方式处理:

  1. PaymentConversation 创建本地支付记录并发送账单。
  2. Webhook 入口单独识别 pre_checkout_query,验证订单后立即调用 answerPreCheckoutQuery。
  3. Webhook 收到 message.successful_payment 后,在事务中写入数据库。
  4. 支付成功后,通过 BotMan 主动启动 MainConversation。

支付仍然处于同一业务流程中,只是 Telegram 支付事件交给 webhook 处理,不必强行包装成普通对话消息。

为什么 ask() 收不到 pre_checkout_query

Telegram 的几类更新采用不同的结构:

普通消息:
update.message

预结账查询:
update.pre_checkout_query

支付成功:
update.message.successful_payment

BotMan 的 Conversation 工作在 Driver 解析后的消息层。执行 ask() 后,BotMan 等待的是下一条可识别的 IncomingMessage,而不是任意一种 Telegram Update。

pre_checkout_query 还有两个特点:

  • 它没有普通消息中的 message.text。
  • 它包含 from.id,但没有可直接使用的 message.chat.id。

即使创建了独立的 TelegramPreCheckoutQueryDriver,只要它的 Driver 标识、用户 ID 或会话键与原 Telegram Driver 不一致,BotMan 仍可能把它视为另一个会话,导致之前的 PaymentConversation 无法恢复。

推荐的支付流程

支付流程应以数据库中的订单状态为准,不应依赖 Conversation 是否还在等待:

PaymentConversation
    ↓
创建 pending 支付记录
    ↓
发送 sendInvoice
    ↓
结束当前 Conversation
    ↓
收到 pre_checkout_query
    ↓
检查 payload、用户、金额和币种
    ↓
调用 answerPreCheckoutQuery
    ↓
收到 successful_payment
    ↓
幂等写入数据库并开通权限
    ↓
启动 MainConversation

不要在 pre_checkout_query 阶段认定支付已经成功。answerPreCheckoutQuery 只表示允许 Telegram 继续扣款,真正的成功凭据是随后收到的 successful_payment。

在 PaymentConversation 中发送账单

建议先创建本地支付记录,再把唯一且不可预测的订单标识写入 payload:

use Illuminate\Support\Str;

class PaymentConversation extends Conversation
{
    public function run()
    {
        $payment = Payment::create([
            'user_id' => $this->bot->getUser()->getId(),
            'payload' => (string) Str::uuid(),
            'currency' => 'USD',
            'amount' => 100000,
            'status' => 'pending',
        ]);

        $invoice = [
            'title' => 'Advanced access',
            'description' => 'Paid advanced access',
            'payload' => $payment->payload,
            'provider_token' => config('services.telegram.provider_token'),
            'currency' => $payment->currency,
            'prices' => json_encode([
                [
                    'label' => 'Advanced access',
                    'amount' => $payment->amount,
                ],
            ]),
        ];

        $this->bot->sendRequest('sendInvoice', $invoice);

        $this->say('账单已经发送,请在 Telegram 中完成支付。');
    }
}

amount 使用货币的最小单位。以 USD 为例,100000 表示 1000.00 USD,不是 100000 USD。

发送账单后不需要继续调用 ask() 等待付款。Telegram 支付可能过很久才完成,更新也可能被重复推送。使用持久化的订单状态更可靠。

在 webhook 入口分流支付更新

如果标准 Telegram Driver 无法识别 pre_checkout_query,应在调用 $botman->listen() 前处理这类更新。

下面是结构示例,具体的 Controller 和配置路径需要按照当前 BotMan Studio 项目调整:

use BotMan\BotMan\BotMan;
use Illuminate\Http\Request;

class BotManController extends Controller
{
    public function handle(
        Request $request,
        BotMan $botman,
        TelegramPaymentHandler $paymentHandler
    ) {
        $update = $request->all();

        if (isset($update['pre_checkout_query'])) {
            $paymentHandler->handlePreCheckoutQuery(
                $update['pre_checkout_query']
            );

            return response('OK', 200);
        }

        if (isset($update['message']['successful_payment'])) {
            $paymentHandler->handleSuccessfulPayment(
                $update['message'],
                $botman
            );

            return response('OK', 200);
        }

        $botman->listen();

        return response('OK', 200);
    }
}

支付更新处理完后应直接返回,避免再把它交给不支持该更新结构的 Driver。

响应 pre_checkout_query

确认预结账查询前,至少要检查以下内容:

  • invoice_payload 是否对应本地的待支付订单。
  • 发起支付的 Telegram 用户是否与订单用户一致。
  • currency 是否一致。
  • total_amount 是否一致。
  • 订单当前是否仍允许支付。
use Illuminate\Support\Facades\Http;

class TelegramPaymentHandler
{
    public function handlePreCheckoutQuery(array $query): void
    {
        $payment = Payment::where(
            'payload',
            $query['invoice_payload'] ?? ''
        )->first();

        $valid = $payment !== null
            && $payment->status === 'pending'
            && (string) $payment->user_id === (string) ($query['from']['id'] ?? '')
            && $payment->currency === ($query['currency'] ?? null)
            && (int) $payment->amount === (int) ($query['total_amount'] ?? -1);

        $parameters = [
            'pre_checkout_query_id' => $query['id'],
            'ok' => $valid,
        ];

        if (!$valid) {
            $parameters['error_message'] =
                '订单信息已失效或金额不一致,请重新创建订单。';
        }

        $this->callTelegram(
            'answerPreCheckoutQuery',
            $parameters
        );
    }

    private function callTelegram(string $method, array $parameters): array
    {
        $token = config('services.telegram.bot_token');

        return Http::asForm()
            ->post(
                "https://api.telegram.org/bot{$token}/{$method}",
                $parameters
            )
            ->throw()
            ->json();
    }
}

也可以使用项目现有的 Telegram API Client。这里直接调用 Telegram Bot API,是因为 pre_checkout_query 尚未由 BotMan Driver 接管,不能假设此时 $botman->sendRequest() 已绑定正确的 Driver。

Telegram 要求 Bot 在很短的时间内响应预结账查询,通常应在 10 秒内完成。因此,此处只做必要验证,不要执行耗时任务。

处理支付成功并启动 MainConversation

支付完成后,Telegram 会发送:

message.successful_payment

收到该更新后,再写入支付流水、开通高级权限,并记录 Telegram 返回的支付标识。

use BotMan\Drivers\Telegram\TelegramDriver;
use Illuminate\Support\Facades\DB;

class TelegramPaymentHandler
{
    public function handleSuccessfulPayment(
        array $message,
        BotMan $botman
    ): void {
        $successfulPayment = $message['successful_payment'];
        $payload = $successfulPayment['invoice_payload'];
        $telegramChargeId =
            $successfulPayment['telegram_payment_charge_id'];

        DB::transaction(function () use (
            $message,
            $successfulPayment,
            $payload,
            $telegramChargeId
        ) {
            $payment = Payment::where('payload', $payload)
                ->lockForUpdate()
                ->firstOrFail();

            // Telegram 可能重试 webhook,已经完成的支付直接跳过。
            if ($payment->status === 'paid') {
                return;
            }

            if (
                (string) $payment->user_id !==
                    (string) $message['from']['id']
                || $payment->currency !==
                    $successfulPayment['currency']
                || (int) $payment->amount !==
                    (int) $successfulPayment['total_amount']
            ) {
                throw new RuntimeException(
                    'Successful payment does not match local order.'
                );
            }

            $payment->update([
                'status' => 'paid',
                'telegram_payment_charge_id' => $telegramChargeId,
                'provider_payment_charge_id' =>
                    $successfulPayment['provider_payment_charge_id'],
                'paid_at' => now(),
            ]);

            User::where('telegram_id', $message['from']['id'])
                ->update([
                    'has_advanced_access' => true,
                ]);
        });

        $chatId = (string) $message['chat']['id'];

        $botman->startConversation(
            new MainConversation(),
            $chatId,
            TelegramDriver::class
        );
    }
}

不同 BotMan 版本中,主动启动会话所需的参数可能略有差异。如果当前版本不接受第三个 Driver 参数,可以先显式使用 Telegram Driver,再调用 startConversation()。无论采用哪种方式,都要把 message.chat.id 作为接收者,不能使用 pre_checkout_query.id。

如果一定要在 Conversation 的回调中处理

理论上,可以扩展 Telegram Driver,把以下两类更新转换为 BotMan 的 IncomingMessage:

pre_checkout_query
message.successful_payment

转换时必须满足以下条件:

  • Driver 名称与启动 PaymentConversation 时使用的 Telegram Driver 一致。
  • sender 使用 pre_checkout_query.from.id。
  • 更新类型和原始支付数据保存在 IncomingMessage 的 extras 中。
  • successful_payment 采用同一套转换规则。
  • Conversation 回调处理完 pre_checkout_query 后,再次进入等待状态。
  • 支付成功状态不能只保存在内存或 Conversation 缓存中。

Conversation 中可以用类似下面的方式读取扩展数据:

$this->ask('请完成支付。', function (Answer $answer) {
    $message = $answer->getMessage();
    $type = $message->getExtras('telegram_update_type');

    if ($type === 'pre_checkout_query') {
        $query = $message->getExtras('pre_checkout_query');

        $this->bot->sendRequest('answerPreCheckoutQuery', [
            'pre_checkout_query_id' => $query['id'],
            'ok' => true,
        ]);

        return;
    }

    if ($type === 'successful_payment') {
        $payment = $message->getExtras('successful_payment');

        // 幂等写入数据库并开通权限。

        $this->bot->startConversation(new MainConversation());
    }
});

不过,addExtras()、getExtras() 以及 Driver 构造消息的方法在不同 BotMan 版本中可能有差异,具体应以项目当前依赖提供的接口为准。

支付通知本身属于异步业务事件。如果强行把它当成 ask() 的回答,还需要处理会话超时、缓存丢失、重复 webhook,以及用户同时操作其他菜单等问题。

因此,更合适的做法是保留标准 Telegram Driver,在 webhook 层处理支付事件。PaymentConversation 负责发起支付,支付处理器负责确认和落库,完成后再回到 MainConversation。

需要特别注意的事项

  • pre_checkout_query.id 只能用于 answerPreCheckoutQuery,不能作为用户 ID、聊天 ID 或订单 ID。
  • 必须使用唯一且不可猜测的 invoice_payload 关联本地订单。
  • 不要仅凭客户端按钮、payload 或 pre_checkout_query 开通权限。
  • successful_payment 必须幂等处理,建议为 telegram_payment_charge_id 添加唯一索引。
  • 数据库更新和权限开通应放在同一个事务中。
  • webhook 返回非 2xx 状态码时,Telegram 可能重试,所以不能假设每个更新只会收到一次。
  • 日志中不要记录 provider_token、Bot Token 或完整的支付敏感信息。
  • 私聊场景可以使用 message.chat.id 主动恢复主菜单。如果还支持群聊,则需要额外确认付款用户与目标聊天之间的对应关系。

备注:内容仅供参考。