From 5b643135e1d197b10819f71a3e501907ffc2c8bf Mon Sep 17 00:00:00 2001 From: hellcat Date: Sun, 11 Oct 2026 12:58:21 +0800 Subject: [PATCH] fix: classify Telegram ticket replies by message id and record safe routing diagnostics --- .../V1/Guest/TelegramController.php | 7 ++++-- app/Services/TelegramTickets/TicketBridge.php | 10 ++++++++ docs/maintenance/STATUS.md | 6 +++++ docs/maintenance/TELEGRAM_TICKETS.md | 4 +++ .../TelegramTickets/TicketBridgeTest.php | 25 +++++++++++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/V1/Guest/TelegramController.php b/app/Http/Controllers/V1/Guest/TelegramController.php index 6d18878..6b86e44 100644 --- a/app/Http/Controllers/V1/Guest/TelegramController.php +++ b/app/Http/Controllers/V1/Guest/TelegramController.php @@ -35,6 +35,9 @@ class TelegramController extends Controller $data = $request->json()->all(); $this->formatMessage($data); + if ($this->msg) { + app(\App\Services\TelegramTickets\TicketBridge::class)->observeIncoming($this->msg->raw_message, (int) $this->msg->update_id); + } $this->formatChatJoinRequest($data); $this->handle(); } @@ -97,9 +100,9 @@ class TelegramController extends Controller 'is_private' => $message['chat']['type'] === 'private', ]; - if (isset($message['reply_to_message']['text'])) { + if (isset($message['reply_to_message']['message_id'])) { $this->msg->message_type = 'reply_message'; - $this->msg->reply_text = $message['reply_to_message']['text']; + $this->msg->reply_text = $message['reply_to_message']['text'] ?? ''; } } diff --git a/app/Services/TelegramTickets/TicketBridge.php b/app/Services/TelegramTickets/TicketBridge.php index ac6f8c5..a589c40 100644 --- a/app/Services/TelegramTickets/TicketBridge.php +++ b/app/Services/TelegramTickets/TicketBridge.php @@ -14,6 +14,16 @@ use Illuminate\Support\Facades\DB; class TicketBridge { public function settings():array {return array_replace(['notify_enabled'=>true,'reply_enabled'=>false,'receiver_user_ids'=>[]],(array)admin_setting('telegram_ticket',[]));} public function botKey():string {return hash('sha256',(string)admin_setting('telegram_bot_token',''));} + public function observeIncoming(array $message,int $updateId):void { + // Keep only short-lived routing facts for bound administrators, never message text or credentials. + if(($message['chat']['type']??'')!=='private'||($message['from']['is_bot']??true)||($message['from']['id']??null)!=($message['chat']['id']??0))return; + try { + $admin=User::where('telegram_id',$message['from']['id'])->where('is_admin',1)->where('banned',0)->first();if(!$admin)return; + $replyId=$message['reply_to_message']['message_id']??null; + $matched=$replyId!==null&&TelegramTicketMessage::where('bot_key',$this->botKey())->where('chat_id',$message['chat']['id'])->where('telegram_message_id',$replyId)->exists(); + Cache::put('tg_ticket_inbound_'.$this->botKey().'_'.$admin->id,['received_at'=>now()->toIso8601String(),'update_id'=>$updateId,'is_reply'=>$replyId!==null,'matched_ticket'=>$matched],3600); + }catch(\Throwable $e){\Illuminate\Support\Facades\Log::warning('Telegram ticket routing diagnostics unavailable');} + } public function receivers() { $q=User::where('is_admin',1)->whereNotNull('telegram_id')->where('banned',0); $ids=$this->settings()['receiver_user_ids'];if($ids)$q->whereIn('id',$ids);return $q->get(); diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index e13f2b4..cd8f025 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -65,3 +65,9 @@ Resend 发信、常规 SMTP、配额回退、投递事件回调、master 一次 检查当前 app 与 plugins-core 发信入口:工单创建/用户追加内容推送 Telegram,管理员回复的邮件收件人为工单提交用户;收款与节点离线通知也通过 Telegram;用量日报由 master 发送 Telegram。未发现专门向管理员邮箱发送上述汇报的代码,因此没有需要移除的管理员邮件汇报实现。本次不新增全局邮箱封锁,不删除验证码、登录邮件、人工群发或正常用户通知;管理员汇报渠道约束已写入 BUSINESS_RULES.md。该核查不是新的真实工单验收。 用户随后明确关闭管理员邮箱汇报应通过开关保留实现,不能删除代码。已更新业务约束;当前尚未找到此类实际邮件入口,本轮没有删代码,也没有添加没有实际对象的开关。若后续增加该类入口,须提供独立默认关闭的开关。线上机器人现已启用,官方回调地址已注册,管理员绑定数仍为零;真实工单链路尚未验收。 + +### Telegram 真实回复排查 + +管理员完成绑定并开启回复后,已通过正式队列向管理员私聊发送一条隔离测试工单通知。用户确认收到并已使用回复功能发送文字,但现场核对仍未发现管理员回复、关联更新记录或 ticket_reply 邮件。绑定、通知映射、回复权限和官方回调 HTTP 200 均已核对,不能据此宣布真实闭环通过。 + +补充真实控制器 → 原插件 → 工单与邮件队列的隔离回归,回复按引用消息编号识别而不依赖引用原文;为已绑定管理员增加一小时自动过期、无正文的路由诊断缓存。工单与邮件测试合计 70 项、383 个断言通过,PHP 语法检查通过。此处记录为本地修正,发布及真实回复验收仍需现场核对。 diff --git a/docs/maintenance/TELEGRAM_TICKETS.md b/docs/maintenance/TELEGRAM_TICKETS.md index 8701955..6a73b07 100644 --- a/docs/maintenance/TELEGRAM_TICKETS.md +++ b/docs/maintenance/TELEGRAM_TICKETS.md @@ -33,3 +33,7 @@ 迁移包含两张新表,应先备份数据库,再通过 Git 发布并执行迁移,刷新后台视图并重载队列。回滚代码时保留消息关联和去重记录,避免重复通知。 隔离测试与网页显示/保存不能替代真实验收。完整验收需管理员完成绑定,使用测试用户提交工单,管理员私聊回复,确认网站保存、邮件实际收到和重复更新不重复写入。记录结果到 STATUS.md;不得保存用户原始工单或令牌。 + +## 回复接收排查 + +回复按 reply_to_message.message_id 识别,不依赖引用原文是否包含 text。对已绑定且未禁用管理员的私聊文字消息,在缓存保留最后一次接收时间、更新编号、是否使用回复、是否命中工单关联,最多一小时;不保存消息正文、账号凭据或原始请求。该记录仅供服务器维护排错,不能以收到回调代替工单写回或邮件送达验收。 diff --git a/tests/Feature/TelegramTickets/TicketBridgeTest.php b/tests/Feature/TelegramTickets/TicketBridgeTest.php index f39c643..a8c34f5 100644 --- a/tests/Feature/TelegramTickets/TicketBridgeTest.php +++ b/tests/Feature/TelegramTickets/TicketBridgeTest.php @@ -94,6 +94,31 @@ class TicketBridgeTest extends TestCase { $this->assertStringContainsString('已绑定其他网站',$this->bridge->bind($this->message(['chat'=>['id'=>999],'from'=>['id'=>999]]),$code)); $this->assertSame(1,TicketMessage::count());$this->assertSame(123,(int)$this->admin->fresh()->telegram_id); } + public function test_real_webhook_routes_reply_by_message_id_without_original_text():void { + $this->app->instance('hook.filters',[]); + require_once base_path('plugins-core/Telegram/Plugin.php'); + Http::fake(['api.telegram.org/*'=>Http::response(['ok'=>true,'result'=>['message_id'=>987]],200)]); + $plugin=new \Plugin\Telegram\Plugin('telegram');$plugin->boot(); + admin_setting(['telegram_webhook_secret'=>'testsecret']); + $message=$this->message(['message_id'=>567]); + $request=\Illuminate\Http\Request::create('/','POST',[],[],[],['CONTENT_TYPE'=>'application/json'],json_encode(['update_id'=>55,'message'=>$message])); + $request->headers->set('X-Telegram-Bot-Api-Secret-Token','testsecret'); + app(\App\Http\Controllers\V1\Guest\TelegramController::class)->webhook($request); + $this->assertSame(2,TicketMessage::count()); + Bus::assertDispatched(SendEmailJob::class,1); + $diagnostic=Cache::get('tg_ticket_inbound_'.$this->bridge->botKey().'_'.$this->admin->id); + $this->assertTrue($diagnostic['is_reply']);$this->assertTrue($diagnostic['matched_ticket']); + $this->assertArrayNotHasKey('text',$diagnostic); + } + public function test_routing_diagnostics_distinguish_unmatched_reply_and_ignore_customers():void { + $this->bridge->observeIncoming($this->message(['reply_to_message'=>['message_id'=>999]]),9); + $key='tg_ticket_inbound_'.$this->bridge->botKey().'_'.$this->admin->id; + $this->assertFalse(Cache::get($key)['matched_ticket']); + $message=$this->message();unset($message['reply_to_message']);$this->bridge->observeIncoming($message,10); + $this->assertFalse(Cache::get($key)['is_reply']); + $this->bridge->observeIncoming($this->message(['from'=>['id'=>999]]),11); + $this->assertSame(10,Cache::get($key)['update_id']); + } public function test_webhook_checks_secret_header_and_refuses_another_bot_webhook():void { Http::fake(['api.telegram.org/*/getMe'=>Http::response(['ok'=>true,'result'=>['username'=>'testbot']],200), 'api.telegram.org/*/getWebhookInfo'=>Http::response(['ok'=>true,'result'=>['url'=>'https://another.example/webhook']],200)]);