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(); } public function enqueue(Ticket $ticket):void { if(!admin_setting('telegram_bot_enable',false)||!$this->settings()['notify_enabled']||!admin_setting('telegram_bot_token'))return; $message=$ticket->relationLoaded('telegram_source_message')?$ticket->getRelation('telegram_source_message'):$ticket->messages()->orderByDesc('id')->first();if(!$message)return; foreach($this->receivers() as $user){ $row=TelegramTicketMessage::firstOrCreate(['bot_key'=>$this->botKey(),'ticket_message_id'=>$message->id,'receiver_user_id'=>$user->id],['ticket_id'=>$ticket->id,'chat_id'=>$user->telegram_id,'status'=>'pending']); if($row->status==='pending')SendTelegramTicketJob::dispatch($row->id)->afterCommit(); } } public function text(TelegramTicketMessage $row):string { $ticket=Ticket::find($row->ticket_id);$message=TicketMessage::find($row->ticket_message_id); if(!$ticket||!$message)throw new \RuntimeException('ticket_missing'); $title=mb_substr($ticket->subject,0,180);$content=mb_substr($message->message,0,2800); return "📮 工单 #{$ticket->id}\n主题:{$title}\n\n{$content}\n\n直接回复这条消息即可回复工单。"; } public function reply(array $message,int $updateId):?string { if(!isset($message['reply_to_message']['message_id']))return null; $chat=$message['chat']['id']??0; $map=TelegramTicketMessage::where('bot_key',$this->botKey())->where('chat_id',$chat)->where('telegram_message_id',$message['reply_to_message']['message_id'])->first(); if(!$map)return null; if(!$this->settings()['reply_enabled'])return 'Telegram 工单回复尚未启用,请在网站后台回复。'; if(($message['chat']['type']??'')!=='private'||($message['from']['is_bot']??true)||($message['from']['id']??null)!=$chat)return '只接受管理员私聊回复。'; $user=User::where('id',$map->receiver_user_id)->where('telegram_id',$message['from']['id'])->where('is_admin',1)->where('banned',0)->first(); if(!$user||!$this->receivers()->contains('id',$user->id))return '当前账号没有工单回复权限。'; if($updateId<=0)return '无法识别消息更新编号,未保存回复。'; $text=trim((string)($message['text']??''));if($text===''||mb_strlen($text)>4000)return '请发送不超过 4000 字的文字回复。'; return DB::transaction(function()use($updateId,$map,$user,$text){ TelegramTicketUpdate::insertOrIgnore(['bot_key'=>$this->botKey(),'update_id'=>$updateId,'status'=>'processing','created_at'=>now(),'updated_at'=>now()]); $update=TelegramTicketUpdate::where('bot_key',$this->botKey())->where('update_id',$updateId)->lockForUpdate()->firstOrFail(); if($update->status==='processed')return '该回复已经处理,不会重复写入或通知。'; $ticket=Ticket::whereKey($map->ticket_id)->lockForUpdate()->first(); if(!$ticket||$ticket->status!=Ticket::STATUS_OPENING){$update->update(['status'=>'processed','result'=>'closed']);return '工单已关闭或不存在,未保存回复。';} if($ticket->user_id===$user->id){$update->update(['status'=>'processed','result'=>'own_ticket']);return '请在网站回复自己提交的工单。';} $reply=app(TicketService::class)->reply($ticket,$text,$user->id); if(!$reply)throw new \RuntimeException('ticket_reply_failed'); $update->update(['status'=>'processed','result'=>'saved','ticket_message_id'=>$reply->id]); DB::afterCommit(fn()=>app(TicketService::class)->notifyAdminReply($ticket,$reply)); return "工单 #{$ticket->id} 回复已保存。邮箱有效时按现有规则发送通知(同一用户 30 分钟内不重复提醒)。"; }); } public function bindingCode(User $admin):string { if(!$admin->is_admin||$admin->banned)throw new \RuntimeException('administrator_required'); $code=bin2hex(random_bytes(24));Cache::put('tg_staff_bind_'.hash('sha256',$code),$admin->id,600);return $code; } public function bind(array $message,string $code):string { if(($message['chat']['type']??'')!=='private'||($message['from']['is_bot']??true)||($message['from']['id']??null)!=($message['chat']['id']??0))return '请使用个人账号与机器人私聊绑定。'; if(!preg_match('/\A[a-f0-9]{48}\z/',$code))return '绑定码无效或已过期。'; $key='tg_staff_bind_'.hash('sha256',$code); return Cache::lock('tg_staff_identity_'.(int)$message['from']['id'],10)->block(3,function()use($key,$message){ return Cache::lock($key.'_lock',10)->block(3,function()use($key,$message){ $id=Cache::get($key);if(!$id)return '绑定码无效或已过期。'; return DB::transaction(function()use($id,$key,$message){ $user=User::whereKey($id)->lockForUpdate()->first();if(!$user||!$user->is_admin||$user->banned)return '管理员账号不可用。'; $tg=$message['from']['id'];if(User::where('telegram_id',$tg)->where('id','!=',$id)->exists())return '这个 Telegram 账号已绑定其他网站账号。'; if($user->telegram_id&&$user->telegram_id!=$tg)return '已绑定其他 Telegram 账号,请先在后台核对解绑。'; $user->telegram_id=$tg;$user->save();DB::afterCommit(fn()=>Cache::forget($key));return '管理员 Telegram 绑定成功。'; }); }); }); } }