dpl/app/Services/TelegramTickets/TicketBridge.php

91 lines
6.8 KiB
PHP

<?php
namespace App\Services\TelegramTickets;
use App\Jobs\SendTelegramTicketJob;
use App\Models\TelegramTicketMessage;
use App\Models\TelegramTicketUpdate;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Models\User;
use App\Services\TicketService;
use Illuminate\Support\Facades\Cache;
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();
}
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 绑定成功。';
});
});
});
}
}