如何在 Botman 对话中处理 Telegram 支付
明确结论
BotMan 的 ask() 只能等待当前 Driver 能转换为 IncomingMessage 的更新。Telegram 的 pre_checkout_query 不属于普通消息,标准 Telegram Driver 通常不会把它传给当前 Conversation,因此仅靠 PaymentConversation 中的 ask() 无法直接监听。
建议按下面的方式处理:
PaymentConversation创建本地支付记录并发送账单。- Webhook 入口单独识别
pre_checkout_query,验证订单后立即调用answerPreCheckoutQuery。 - Webhook 收到
message.successful_payment后,在事务中写入数据库。 - 支付成功后,通过 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主动恢复主菜单。如果还支持群聊,则需要额外确认付款用户与目标聊天之间的对应关系。
备注:内容仅供参考。
