diff --git a/app/Console/Commands/RetryTelegramTickets.php b/app/Console/Commands/RetryTelegramTickets.php new file mode 100644 index 0000000..aaab532 --- /dev/null +++ b/app/Console/Commands/RetryTelegramTickets.php @@ -0,0 +1,12 @@ +where('updated_at','<',now()->subMinutes(5))->update(['status'=>'unknown','error_code'=>'worker_interrupted']); + TelegramTicketMessage::where('status','pending')->where('created_at','<',now()->subMinute())->where(fn($q)=>$q->whereNull('available_at')->orWhere('available_at','<=',now()))->limit(100)->get()->each(fn($r)=>SendTelegramTicketJob::dispatch($r->id));return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index eee3a14..1f604e0 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -27,6 +27,7 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule): void { + $schedule->command('telegram:tickets:retry')->everyMinute()->withoutOverlapping(); Cache::put(CacheKey::get('SCHEDULE_LAST_CHECK_AT', null), time()); // v2board $schedule->command('xboard:statistics')->dailyAt('0:10')->onOneServer(); diff --git a/app/Http/Controllers/V1/Guest/TelegramController.php b/app/Http/Controllers/V1/Guest/TelegramController.php index 01369fa..6d18878 100644 --- a/app/Http/Controllers/V1/Guest/TelegramController.php +++ b/app/Http/Controllers/V1/Guest/TelegramController.php @@ -24,8 +24,11 @@ class TelegramController extends Controller public function webhook(Request $request): void { - $expectedToken = md5(admin_setting('telegram_bot_token')); - if ($request->input('access_token') !== $expectedToken) { + $botToken = (string) admin_setting('telegram_bot_token', ''); + if ($botToken === '' || !admin_setting('telegram_bot_enable', false)) { throw new ApiException('Telegram bot disabled', 401); } + $secret = (string) admin_setting('telegram_webhook_secret', ''); + $expectedToken = md5($botToken); + if ($secret !== '' ? !hash_equals($secret, (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '')) : !hash_equals($expectedToken, (string) $request->input('access_token', ''))) { throw new ApiException('access_token is error', 401); } @@ -50,8 +53,9 @@ class TelegramController extends Controller } HookManager::call('telegram.message.after', [$msg]); } catch (\Exception $e) { + if (($msg->message_type ?? '') === 'reply_message') { throw new ApiException('工单回复暂时未完成,请重试', 500); } HookManager::call('telegram.message.error', [$msg, $e]); - $this->telegramService->sendMessage($msg->chat_id, $e->getMessage()); + $this->telegramService->sendMessage($msg->chat_id, '暂时无法处理,请稍后重试。'); } } @@ -82,6 +86,8 @@ class TelegramController extends Controller $text = explode(' ', $message['text']); $this->msg = (object) [ + 'raw_message' => $message, + 'update_id' => $data['update_id'] ?? 0, 'command' => $text[0], 'args' => array_slice($text, 1), 'chat_id' => $message['chat']['id'], diff --git a/app/Http/Controllers/V2/Admin/ConfigController.php b/app/Http/Controllers/V2/Admin/ConfigController.php index 9f09c86..619adb2 100644 --- a/app/Http/Controllers/V2/Admin/ConfigController.php +++ b/app/Http/Controllers/V2/Admin/ConfigController.php @@ -56,12 +56,15 @@ class ConfigController extends Controller if (blank($hookUrl)) { return $this->fail([422, 'Telegram Webhook地址未配置']); } - $hookUrl .= '?' . http_build_query([ - 'access_token' => md5(admin_setting('telegram_bot_token', $request->input('telegram_bot_token'))) - ]); $telegramService = new TelegramService($request->input('telegram_bot_token')); $telegramService->getMe(); - $telegramService->setWebhook(url: $hookUrl); + $existing = $telegramService->getWebhookInfo()->result->url ?? ''; + if ($existing !== '' && (parse_url($existing, PHP_URL_HOST) !== parse_url($hookUrl, PHP_URL_HOST) || parse_url($existing, PHP_URL_PATH) !== parse_url($hookUrl, PHP_URL_PATH))) { + return $this->fail([422, '机器人已有其他 Webhook,未覆盖。请使用独立客服机器人或先确认现有用途。']); + } + $secret = bin2hex(random_bytes(32)); + $telegramService->setWebhook($hookUrl, $secret); + admin_setting(['telegram_webhook_secret' => $secret, 'telegram_bot_enable' => true]); $telegramService->registerBotCommands(); return $this->success([ 'success' => true, diff --git a/app/Http/Controllers/V2/Admin/TelegramTicketController.php b/app/Http/Controllers/V2/Admin/TelegramTicketController.php new file mode 100644 index 0000000..e35c540 --- /dev/null +++ b/app/Http/Controllers/V2/Admin/TelegramTicketController.php @@ -0,0 +1,25 @@ +success(['settings'=>$bridge->settings(),'bot_configured'=>(bool)admin_setting('telegram_bot_token'), + 'bot_enabled'=>(bool)admin_setting('telegram_bot_enable'), 'secure_webhook_configured'=>(bool)admin_setting('telegram_webhook_secret'), + 'admins'=>User::where('is_admin',1)->where('banned',0)->get(['id','email','telegram_id'])->map(fn($u)=>['id'=>$u->id,'email'=>$u->email,'bound'=>(bool)$u->telegram_id]), + 'delivery_counts'=>TelegramTicketMessage::selectRaw('status, count(*) as total')->groupBy('status')->pluck('total','status')]); + } + public function save(Request $request,TicketBridge $bridge){ + $data=$request->validate(['notify_enabled'=>'required|boolean','reply_enabled'=>'required|boolean','receiver_user_ids'=>'present|array|max:20','receiver_user_ids.*'=>'integer|distinct']); + $ids=$data['receiver_user_ids'];if($ids&&User::whereIn('id',$ids)->where('is_admin',1)->where('banned',0)->count()!==count($ids))return $this->fail([422,'接收人必须是有效管理员']); + admin_setting(['telegram_ticket'=>$data]);return $this->success($bridge->settings()); + } + public function bindCode(Request $request,TicketBridge $bridge){ + $admin=$request->user();if(!$admin||!$admin->is_admin||$admin->banned)return $this->fail([403,'仅管理员可绑定']); + if(!admin_setting('telegram_bot_token'))return $this->fail([422,'请先配置客服机器人']); + return $this->success(['command'=>'/staffbind '.$bridge->bindingCode($admin),'expires_in'=>600]); + } +} diff --git a/app/Http/Routes/V2/AdminRoute.php b/app/Http/Routes/V2/AdminRoute.php index 95a79d5..f38d657 100644 --- a/app/Http/Routes/V2/AdminRoute.php +++ b/app/Http/Routes/V2/AdminRoute.php @@ -35,6 +35,9 @@ class AdminRoute 'prefix' => 'config' ], function ($router) { $router->get('/fetch', [ConfigController::class, 'fetch']); + $router->get('/telegramTicket', [\App\Http\Controllers\V2\Admin\TelegramTicketController::class, 'fetch']); + $router->post('/telegramTicket', [\App\Http\Controllers\V2\Admin\TelegramTicketController::class, 'save']); + $router->post('/telegramTicketBind', [\App\Http\Controllers\V2\Admin\TelegramTicketController::class, 'bindCode']); $router->get('/mailFlowIncoming', [\App\Http\Controllers\V2\Admin\MailFlowController::class, 'incoming']); $router->get('/mailFlowStatus', [\App\Http\Controllers\V2\Admin\MailFlowController::class, 'status']); $router->post('/save', [ConfigController::class, 'save']); diff --git a/app/Jobs/SendTelegramTicketJob.php b/app/Jobs/SendTelegramTicketJob.php new file mode 100644 index 0000000..0905510 --- /dev/null +++ b/app/Jobs/SendTelegramTicketJob.php @@ -0,0 +1,37 @@ +onQueue('send_telegram');} + public function handle(TicketBridge $bridge):void { + $row=DB::transaction(function()use($bridge){ + $r=TelegramTicketMessage::whereKey($this->mappingId)->lockForUpdate()->first();if(!$r||$r->status!=='pending'||($r->available_at&&$r->available_at->isFuture()))return null; + if($r->bot_key!==$bridge->botKey()||!admin_setting('telegram_bot_enable')||!$bridge->settings()['notify_enabled']||!$bridge->receivers()->contains('id',$r->receiver_user_id)){$r->update(['status'=>'cancelled']);return null;} + $user=User::find($r->receiver_user_id);if(!$user||$user->telegram_id!=$r->chat_id){$r->update(['status'=>'cancelled']);return null;} + $r->update(['status'=>'sending']);return $r; + }); + if(!$row)return; + try { + $text=$bridge->text($row); + // No automatic HTTP retries: a timeout may mean Telegram accepted the message. + $r=Http::timeout(20)->post('https://api.telegram.org/bot'.admin_setting('telegram_bot_token').'/sendMessage',['chat_id'=>$row->chat_id,'text'=>$text]); + $data=$r->json(); + if($r->status()===429){$delay=max(5,(int)($data['parameters']['retry_after']??30));$row->update(['status'=>'pending','error_code'=>'rate_limited','available_at'=>now()->addSeconds($delay)]);$this->release($delay);return;} + if($r->successful()&&($data['ok']??false)&&isset($data['result']['message_id'])){$row->update(['telegram_message_id'=>$data['result']['message_id'],'status'=>'sent','error_code'=>null]);return;} + $row->update(['status'=>$r->serverError()?'unknown':'failed','error_code'=>'telegram_rejected']); + }catch(\Throwable $e){$row->update(['status'=>'unknown','error_code'=>'submission_unconfirmed']);} + } +} diff --git a/app/Models/TelegramTicketMessage.php b/app/Models/TelegramTicketMessage.php new file mode 100644 index 0000000..ab87f12 --- /dev/null +++ b/app/Models/TelegramTicketMessage.php @@ -0,0 +1,7 @@ +'datetime','chat_id'=>'integer','telegram_message_id'=>'integer','ticket_id'=>'integer','receiver_user_id'=>'integer']; +} diff --git a/app/Models/TelegramTicketUpdate.php b/app/Models/TelegramTicketUpdate.php new file mode 100644 index 0000000..3a8ee12 --- /dev/null +++ b/app/Models/TelegramTicketUpdate.php @@ -0,0 +1,4 @@ +apiUrl = "https://api.telegram.org/bot{$botToken}/"; $this->http = Http::timeout(30) @@ -59,12 +59,17 @@ class TelegramService return $this->request('getMe'); } - public function setWebhook(string $url): object + public function setWebhook(string $url, string $secret = ''): object { - $result = $this->request('setWebhook', ['url' => $url]); + $result = $this->request('setWebhook', ['url' => $url] + ($secret !== '' ? ['secret_token' => $secret] : [])); return $result; } + public function getWebhookInfo(): object + { + return $this->request('getWebhookInfo'); + } + /** * 注册 Bot 命令列表 */ @@ -150,11 +155,10 @@ class TelegramService } catch (\Exception $e) { Log::error('Telegram API 请求失败', [ 'method' => $method, - 'params' => $params, - 'error' => $e->getMessage(), + 'error_type' => get_class($e), ]); - throw new ApiException("Telegram 服务错误: {$e->getMessage()}"); + throw new ApiException("Telegram 服务请求失败,请核对机器人配置与网络。"); } } } diff --git a/app/Services/TelegramTickets/TicketBridge.php b/app/Services/TelegramTickets/TicketBridge.php new file mode 100644 index 0000000..ac6f8c5 --- /dev/null +++ b/app/Services/TelegramTickets/TicketBridge.php @@ -0,0 +1,80 @@ +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 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 绑定成功。'; + }); + }); + }); + } +} diff --git a/app/Services/TicketService.php b/app/Services/TicketService.php index 5521f5f..bfe1d71 100644 --- a/app/Services/TicketService.php +++ b/app/Services/TicketService.php @@ -30,6 +30,7 @@ class TicketService if (!$ticketMessage || !$ticket->save()) { throw new \Exception(); } + $ticket->setRelation('telegram_source_message', $ticketMessage); DB::commit(); return $ticketMessage; } catch (\Exception $e) { @@ -48,7 +49,13 @@ class TicketService if (!$ticketMessage) { throw new ApiException('工单回复失败'); } - HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]); + $this->notifyAdminReply($ticket, $ticketMessage); + } + + public function notifyAdminReply(Ticket $ticket, TicketMessage $ticketMessage): void + { + try { HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]); } + catch (\Throwable $e) { \Illuminate\Support\Facades\Log::warning('Ticket reply hook failed', ['ticket_id' => $ticket->id]); } try { $this->sendEmailNotify($ticket, $ticketMessage); } catch (\Throwable $e) { @@ -83,6 +90,7 @@ class TicketService DB::rollBack(); throw new ApiException('工单消息创建失败'); } + $ticket->setRelation('telegram_source_message', $ticketMessage); DB::commit(); return $ticket; } catch (\Exception $e) { @@ -112,7 +120,7 @@ class TicketService 'url' => admin_setting('app_url'), 'content' => "主题:{$ticket->subject}\r\n回复内容:{$ticketMessage->message}" ] - ]); + ])->afterCommit(); } } } diff --git a/database/migrations/2026_10_11_140000_create_telegram_ticket_tables.php b/database/migrations/2026_10_11_140000_create_telegram_ticket_tables.php new file mode 100644 index 0000000..8577c6e --- /dev/null +++ b/database/migrations/2026_10_11_140000_create_telegram_ticket_tables.php @@ -0,0 +1,19 @@ +id();$t->string('bot_key',64);$t->unsignedBigInteger('ticket_id');$t->unsignedBigInteger('ticket_message_id');$t->unsignedBigInteger('receiver_user_id'); + $t->bigInteger('chat_id');$t->bigInteger('telegram_message_id')->nullable();$t->string('status',20)->default('pending');$t->string('error_code',60)->nullable();$t->timestamp('available_at')->nullable();$t->timestamps(); + $t->unique(['bot_key','ticket_message_id','receiver_user_id'],'tg_ticket_source_recipient'); + $t->unique(['bot_key','chat_id','telegram_message_id'],'tg_ticket_message_mapping');$t->index(['status','created_at']); + }); + Schema::create('v2_telegram_ticket_updates',function(Blueprint $t){ + $t->id();$t->string('bot_key',64);$t->bigInteger('update_id');$t->string('status',20)->default('processing');$t->unsignedBigInteger('ticket_message_id')->nullable();$t->string('result',60)->nullable();$t->timestamps(); + $t->unique(['bot_key','update_id'],'tg_ticket_unique_update'); + }); + } + public function down():void {Schema::dropIfExists('v2_telegram_ticket_updates');Schema::dropIfExists('v2_telegram_ticket_messages');} +}; diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md index d3de393..36b73ee 100644 --- a/docs/maintenance/README.md +++ b/docs/maintenance/README.md @@ -18,6 +18,7 @@ | ARCHITECTURE.md | 服务入口、前后端边界、配置来源、开发与检查 | | BUSINESS_RULES.md | 邮件、提醒、账号与支付等不可随意改变的约束 | | DEPLOYMENT.md | Git 发布、备份、运行依赖、恢复及故障排查 | +| TELEGRAM_TICKETS.md | Telegram 工单接入、绑定、权限及故障处理 | | STATUS.md | 最近验证基线、已知限制与待验收项目 | 这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。 diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index c6a30f7..c92a00b 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -45,3 +45,11 @@ Resend 发信、常规 SMTP、配额回退、投递事件回调、master 一次 ## 每次维护更新本文件 记录任务、修改文件、测试、提交、是否推送/部署、真实验收结果、尚未完成项与敏感边界。禁止写密码、令牌、原始用户邮件或未经核实的完成结论。 + +## Telegram 工单实现(2026-10-11) + +沿用原有 Telegram 插件与机器人接口,未对接 master。新增工单推送与消息关联、管理员私聊绑定及权限校验、更新编号去重、未知发送结果不自动重试、429 延迟重试和现有邮件通知复用。新增设置直接位于 Telegram 设置页面,无悬浮入口。 + +本地新迁移执行成功;工单与既有邮件回归合计 68 项测试、375 个断言通过,PHP 与 JavaScript 语法检查通过。浏览器验证新增区域显示、保存、刷新持久化以及切换邮件设置后返回恢复;测试开关已恢复推送开启、回复关闭。详细操作见 TELEGRAM_TICKETS.md。 + +用户已在线上保存机器人令牌;只读检查确认令牌配置存在,官方 getWebhookInfo 成功,但机器人开关仍关闭、回调地址为空、绑定管理员为零。本节记录时新代码尚未部署,真实工单推送、Telegram 回复和邮件通知闭环尚未验收。 diff --git a/docs/maintenance/TELEGRAM_TICKETS.md b/docs/maintenance/TELEGRAM_TICKETS.md new file mode 100644 index 0000000..8701955 --- /dev/null +++ b/docs/maintenance/TELEGRAM_TICKETS.md @@ -0,0 +1,35 @@ +# Telegram 工单通知与回复 + +## 接入范围 + +沿用本项目 Telegram 插件、网站机器人令牌、官方 Bot API 与原有 webhook。不接入 telegrammaster/master 项目;已有用户绑定、流量和订阅命令保留。该机器人可供用户与管理员共用,管理员工单回复需要独立权限校验。邮件仍由网站发送。 + +第一版仅支持管理员与机器人的私聊文字回复,不支持群聊、附件、编辑 Telegram 消息以修改已有工单回复。网站工单表与页面继续使用原有逻辑。 + +## 后台设置与启用 + +进入 /dove 的系统配置 → Telegram 设置 → 工单通知与回复。机器人令牌仍在原有字段保存。新增推送开关、回复开关和接收管理员选择;不选接收人代表全部已绑定且未禁用的管理员。 + +“连接机器人并启用回调”使用服务器保存的令牌,先核对 getMe 和 getWebhookInfo,拒绝覆盖其他网站/路径已有的 webhook。连接成功时启用机器人并保存随机 webhook secret;新回调通过 Telegram secret header 验证,未升级的既有回调暂兼容原 token 哈希验证。不能同时让其他服务 getUpdates 或占用同一机器人 webhook。 + +管理员点击“绑定我的 Telegram”,将十分钟内有效的一次性命令私聊发送给机器人。绑定码只存缓存,数据库记录管理员与 Telegram 的关联;代码不替换已有其他绑定,不允许将同一 Telegram 绑定到其他网站账号。绑定后刷新页面,并按需要开启回复。绑定码不要写入文档、日志或截图。 + +机器人没有绑定接收管理员前不会推送工单;回复开关默认关闭。该功能使用原有 Telegram 插件的 enable_ticket_notify 选项,若关闭该插件通知选项,仍不会推送。 + +## 消息处理 + +网站创建工单或用户追加内容后,原插件钩子排队发送通知。新增 v2_telegram_ticket_messages 保存来源工单消息、接收管理员、机器人令牌指纹和 Telegram 消息编号。正文截断过长文字,纯文本发送,减少多余个人数据披露。 + +管理员必须直接回复机器人发出的已关联通知。按机器人、私聊与消息编号查关联,再验证实际发送人、绑定管理员、禁用状态和接收人范围;不从转发消息或文字中的工单编号推断权限。已关闭工单拒绝回复。v2_telegram_ticket_updates 的唯一更新编号和事务锁防止 Telegram 重送导致重复写入/邮件通知。保存失败返回非成功响应,允许服务商重试;已保存回复不会因确认消息发送失败而重复写入。 + +回复通过 TicketService 写入原工单。有效用户邮箱才通知,仍使用 ticket_reply 的 Resend 优先/额度回退和同一用户三十分钟内不重复提醒的规则,不受自动邮件三档影响。无邮箱、邮件或其他通知钩子失败不回滚已保存回复;日志只记必要标识。 + +## 队列与失败处理 + +使用原有 send_telegram 队列。telegram:tickets:retry 每分钟恢复未发送任务;发送中断超过五分钟记为结果未知。明确 429 按 retry_after 延后,不提前重发。HTTP 超时和服务端异常可能已提交,记为 unknown,不自动重发。明确拒绝记为 failed。更换机器人令牌、撤销接收人或关闭推送后,待发送任务会取消。 + +## 维护与验收 + +迁移包含两张新表,应先备份数据库,再通过 Git 发布并执行迁移,刷新后台视图并重载队列。回滚代码时保留消息关联和去重记录,避免重复通知。 + +隔离测试与网页显示/保存不能替代真实验收。完整验收需管理员完成绑定,使用测试用户提交工单,管理员私聊回复,确认网站保存、邮件实际收到和重复更新不重复写入。记录结果到 STATUS.md;不得保存用户原始工单或令牌。 diff --git a/plugins-core/Telegram/Plugin.php b/plugins-core/Telegram/Plugin.php index a5c61b1..80a5ff5 100644 --- a/plugins-core/Telegram/Plugin.php +++ b/plugins-core/Telegram/Plugin.php @@ -71,41 +71,8 @@ class Plugin extends AbstractPlugin return; } - $message = $ticket->messages()->latest()->first(); - $user = User::find($ticket->user_id); - if (!$user) - return; - $user->load('plan'); - $transfer_enable = $this->transferToGBString($user->transfer_enable); - $remaining_traffic = $this->transferToGBString($user->transfer_enable - $user->u - $user->d); - $u = $this->transferToGBString($user->u); - $d = $this->transferToGBString($user->d); - $expired_at = $user->expired_at ? date('Y-m-d H:i:s', $user->expired_at) : '长期有效'; - $money = $user->balance / 100; - $affmoney = $user->commission_balance / 100; - $plan = $user->plan; - $ip = request()?->ip() ?? ''; - $region = $ip ? (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? (new \Ip2Region())->simple($ip) : 'NULL') : ''; - $TGmessage = "📮 *工单提醒* #{$ticket->id}\n"; - $TGmessage .= "━━━━━━━━━━━━━━━━━━━━\n"; - $TGmessage .= "📧 邮箱: `{$user->email}`\n"; - $TGmessage .= "📍 位置: `{$region}`\n"; - - if ($plan) { - $TGmessage .= "📦 套餐: `" . Helper::escapeMarkdown($plan->name) . "`\n"; - $TGmessage .= "📊 流量: `{$remaining_traffic}G / {$transfer_enable}G` (剩余/总计)\n"; - $TGmessage .= "⬆️⬇️ 已用: `{$u}G / {$d}G`\n"; - $TGmessage .= "⏰ 到期: `{$expired_at}`\n"; - } else { - $TGmessage .= "📦 套餐: `未订购任何套餐`\n"; - } - - $TGmessage .= "💰 余额: `{$money}元`\n"; - $TGmessage .= "💸 佣金: `{$affmoney}元`\n"; - $TGmessage .= "━━━━━━━━━━━━━━━━━━━━\n"; - $TGmessage .= "📝 *主题*: `" . Helper::escapeMarkdown($ticket->subject) . "`\n"; - $TGmessage .= "💬 *内容*: `" . Helper::escapeMarkdown($message->message) . "`"; - $this->telegramService->sendMessageWithAdmin($TGmessage, true); + try { app(\App\Services\TelegramTickets\TicketBridge::class)->enqueue($ticket); } + catch (\Throwable $e) { Log::warning('Telegram ticket notification could not be queued', ['ticket_id' => $ticket->id]); } } protected function registerDefaultCommands(): void @@ -114,6 +81,7 @@ class Plugin extends AbstractPlugin $this->registerTelegramCommand($command, [$this, $config['handler']]); } + $this->registerTelegramCommand('/staffbind', [$this, 'handleStaffBind']); $this->registerReplyHandler('/(📮.*?工单提醒.*?#?|工单ID: ?)(\\d+)/', [$this, 'handleTicketReply']); } @@ -160,6 +128,12 @@ class Plugin extends AbstractPlugin return $user; } + public function handleStaffBind(object $msg): void + { + $result = app(\App\Services\TelegramTickets\TicketBridge::class)->bind($msg->raw_message ?? [], (string) ($msg->args[0] ?? '')); + $this->telegramService->sendMessage($msg->chat_id, $result); + } + public function handleStartCommand(object $msg): void { $welcomeTitle = $this->getConfig('start_welcome_title', '🎉 欢迎使用 XBoard Telegram Bot!'); @@ -196,6 +170,7 @@ class Plugin extends AbstractPlugin default => false }; } catch (\Exception $e) { + if (($msg->message_type ?? '') === 'reply_message') { throw new \App\Exceptions\ApiException('工单回复暂时未完成,请重试', 500); } Log::error('Telegram 命令处理意外错误', [ 'command' => $msg->command ?? 'unknown', 'chat_id' => $msg->chat_id ?? 'unknown', @@ -228,12 +203,9 @@ class Plugin extends AbstractPlugin return false; } - foreach ($this->commands['replies'] as $regex => $handler) { - if (preg_match($regex, $msg->reply_text, $matches)) { - call_user_func($handler, $msg, $matches); - return true; - } - } + $bridge = app(\App\Services\TelegramTickets\TicketBridge::class); + $result = $bridge->reply($msg->raw_message ?? [], (int) ($msg->update_id ?? 0)); + if ($result !== null) { $this->sendMessage($msg, $result); return true; } return false; } @@ -386,32 +358,8 @@ class Plugin extends AbstractPlugin public function handleTicketReply(object $msg, array $matches): void { - $user = $this->getBoundUser($msg); - if (!$user) { - return; - } - - if (!isset($matches[2]) || !is_numeric($matches[2])) { - Log::warning('Telegram 工单回复正则未匹配到工单ID', ['matches' => $matches, 'msg' => $msg]); - $this->sendMessage($msg, '未能识别工单ID,请直接回复工单提醒消息。'); - return; - } - - $ticketId = (int) $matches[2]; - $ticket = Ticket::where('id', $ticketId)->first(); - if (!$ticket) { - $this->sendMessage($msg, '工单不存在'); - return; - } - - $ticketService = new TicketService(); - $ticketService->replyByAdmin( - $ticketId, - $msg->text, - $user->id - ); - - $this->sendMessage($msg, "工单 #{$ticketId} 回复成功"); + // Legacy text-derived ticket IDs are no longer accepted. + $this->sendMessage($msg, '请直接回复机器人发送的新工单提醒消息。'); } /** diff --git a/public/admin-custom/telegram-tickets.js b/public/admin-custom/telegram-tickets.js new file mode 100644 index 0000000..cb3be0e --- /dev/null +++ b/public/admin-custom/telegram-tickets.js @@ -0,0 +1,22 @@ +const fieldClass='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm'; +const buttonClass='inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground shadow h-9 px-4 py-2 disabled:opacity-50'; +function el(tag,text,cls){const n=document.createElement(tag);if(text)n.textContent=text;if(cls)n.className=cls;return n;} +async function api(action,payload){let token;try{token=JSON.parse(localStorage.getItem('XBOARD_ACCESS_TOKEN')||'null');}catch{}if(!token?.value)throw new Error('请重新登录后台'); + const base=new URL(window.settings?.base_url||'/',location.origin);if(base.origin!==location.origin)throw new Error('接口必须同源'); + const r=await fetch(base.pathname.replace(/\/$/,'')+'/api/v2/'+encodeURIComponent(window.settings.secure_path)+'/config/'+action,{method:payload?'POST':'GET',credentials:'same-origin',headers:{Authorization:token.value,'Content-Type':'application/json'},...(payload?{body:JSON.stringify(payload)}:{})});const j=await r.json();if(!r.ok||j.status==='fail')throw new Error(j.message||'保存失败');return j.data; +} +async function render(host){try{const data=await api('telegramTicket');if(!host.isConnected)return;host.replaceChildren(); + host.append(el('h3','工单通知与回复','font-semibold text-lg'),el('p','管理员私聊接收工单;直接回复通知即可写回网站。邮件沿用原有渠道与 30 分钟提醒间隔。','text-sm text-muted-foreground')); + const status=el('p',data.bot_configured?(data.bot_enabled?'机器人已配置并启用':'机器人已配置,尚未启用'):'请先在上方配置客服机器人。','text-sm');host.append(status);const connect=el('button','连接机器人并启用回调',buttonClass);connect.type='button';connect.disabled=!data.bot_configured;connect.addEventListener('click',async()=>{connect.disabled=true;try{await api('setTelegramWebhook',{});await render(host);}catch(e){status.textContent=e.message;connect.disabled=false;}});host.append(connect); + const form=el('form',null,'space-y-4');const fields={};for(const [key,label]of[['notify_enabled','向管理员推送新工单和用户追加内容'],['reply_enabled','允许管理员在 Telegram 回复工单']]){const wrap=el('label',null,'flex items-center gap-3 text-sm');const input=el('input');input.type='checkbox';input.checked=!!data.settings[key];fields[key]=input;wrap.append(input,el('span',label));form.append(wrap);} + form.append(el('p','接收管理员(不选则使用全部已绑定管理员;禁用账号不接收)','text-sm text-muted-foreground'));const admins=[]; + for(const a of data.admins){const wrap=el('label',null,'flex items-center gap-3 text-sm');const i=el('input');i.type='checkbox';i.checked=data.settings.receiver_user_ids.map(Number).includes(a.id);wrap.append(i,el('span',a.email+' · '+(a.bound?'已绑定':'未绑定')));admins.push([a.id,i]);form.append(wrap);} + const save=el('button','保存工单设置',buttonClass);save.type='submit';const feedback=el('p',null,'text-sm');form.append(save,feedback);form.addEventListener('submit',async e=>{e.preventDefault();save.disabled=true;try{await api('telegramTicket',{notify_enabled:fields.notify_enabled.checked,reply_enabled:fields.reply_enabled.checked,receiver_user_ids:admins.filter(([,n])=>n.checked).map(([id])=>id)});feedback.textContent='已保存';}catch(e){feedback.textContent=e.message;}finally{save.disabled=false;}});host.append(form); + const bind=el('button','绑定我的 Telegram',buttonClass);bind.type='button';bind.disabled=!data.bot_configured;const command=el('input',null,fieldClass);command.readOnly=true;command.hidden=true;const hint=el('p',null,'text-sm text-muted-foreground');bind.addEventListener('click',async()=>{bind.disabled=true;try{const r=await api('telegramTicketBind',{});command.value=r.command;command.hidden=false;hint.textContent='请在 10 分钟内将这条命令私聊发送给客服机器人。绑定完成后刷新本页;绑定码不要转发。';setTimeout(()=>{if(command.isConnected){command.value='';command.hidden=true;hint.textContent='绑定码已过期,请重新获取。';}},r.expires_in*1000);}catch(e){hint.textContent=e.message;}finally{bind.disabled=false;}});host.append(bind,command,hint); + const counts=data.delivery_counts||{};host.append(el('p','工单推送:待发送 '+(counts.pending||0)+',已发送 '+(counts.sent||0)+',失败 '+(counts.failed||0)+',结果未知 '+(counts.unknown||0)+'。结果未知不会自动重发,避免重复通知。','text-sm text-muted-foreground')); +}catch(e){host.replaceChildren(el('p',e.message,'text-sm text-destructive'));const retry=el('button','重新读取',buttonClass);retry.addEventListener('click',()=>render(host));host.append(retry);}} +let queued=false;function mount(){queued=false;const old=document.getElementById('telegram-ticket-settings');if(!location.hash.startsWith('#/config/system/telegram')){old?.remove();return;}if(old)return; + const heading=[...document.querySelectorAll('h3')].find(n=>n.textContent.trim()==='Telegram设置');const panel=heading?.parentElement?.parentElement;if(!panel)return; + const host=el('section',null,'space-y-4 rounded-xl border bg-card text-card-foreground p-6 mt-6');host.id='telegram-ticket-settings';panel.append(host);render(host); +} +function schedule(){if(!queued){queued=true;requestAnimationFrame(mount);}}new MutationObserver(schedule).observe(document.body,{childList:true,subtree:true});window.addEventListener('hashchange',schedule);schedule(); diff --git a/resources/js/admin/telegram-tickets.js b/resources/js/admin/telegram-tickets.js new file mode 100644 index 0000000..cb3be0e --- /dev/null +++ b/resources/js/admin/telegram-tickets.js @@ -0,0 +1,22 @@ +const fieldClass='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm'; +const buttonClass='inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground shadow h-9 px-4 py-2 disabled:opacity-50'; +function el(tag,text,cls){const n=document.createElement(tag);if(text)n.textContent=text;if(cls)n.className=cls;return n;} +async function api(action,payload){let token;try{token=JSON.parse(localStorage.getItem('XBOARD_ACCESS_TOKEN')||'null');}catch{}if(!token?.value)throw new Error('请重新登录后台'); + const base=new URL(window.settings?.base_url||'/',location.origin);if(base.origin!==location.origin)throw new Error('接口必须同源'); + const r=await fetch(base.pathname.replace(/\/$/,'')+'/api/v2/'+encodeURIComponent(window.settings.secure_path)+'/config/'+action,{method:payload?'POST':'GET',credentials:'same-origin',headers:{Authorization:token.value,'Content-Type':'application/json'},...(payload?{body:JSON.stringify(payload)}:{})});const j=await r.json();if(!r.ok||j.status==='fail')throw new Error(j.message||'保存失败');return j.data; +} +async function render(host){try{const data=await api('telegramTicket');if(!host.isConnected)return;host.replaceChildren(); + host.append(el('h3','工单通知与回复','font-semibold text-lg'),el('p','管理员私聊接收工单;直接回复通知即可写回网站。邮件沿用原有渠道与 30 分钟提醒间隔。','text-sm text-muted-foreground')); + const status=el('p',data.bot_configured?(data.bot_enabled?'机器人已配置并启用':'机器人已配置,尚未启用'):'请先在上方配置客服机器人。','text-sm');host.append(status);const connect=el('button','连接机器人并启用回调',buttonClass);connect.type='button';connect.disabled=!data.bot_configured;connect.addEventListener('click',async()=>{connect.disabled=true;try{await api('setTelegramWebhook',{});await render(host);}catch(e){status.textContent=e.message;connect.disabled=false;}});host.append(connect); + const form=el('form',null,'space-y-4');const fields={};for(const [key,label]of[['notify_enabled','向管理员推送新工单和用户追加内容'],['reply_enabled','允许管理员在 Telegram 回复工单']]){const wrap=el('label',null,'flex items-center gap-3 text-sm');const input=el('input');input.type='checkbox';input.checked=!!data.settings[key];fields[key]=input;wrap.append(input,el('span',label));form.append(wrap);} + form.append(el('p','接收管理员(不选则使用全部已绑定管理员;禁用账号不接收)','text-sm text-muted-foreground'));const admins=[]; + for(const a of data.admins){const wrap=el('label',null,'flex items-center gap-3 text-sm');const i=el('input');i.type='checkbox';i.checked=data.settings.receiver_user_ids.map(Number).includes(a.id);wrap.append(i,el('span',a.email+' · '+(a.bound?'已绑定':'未绑定')));admins.push([a.id,i]);form.append(wrap);} + const save=el('button','保存工单设置',buttonClass);save.type='submit';const feedback=el('p',null,'text-sm');form.append(save,feedback);form.addEventListener('submit',async e=>{e.preventDefault();save.disabled=true;try{await api('telegramTicket',{notify_enabled:fields.notify_enabled.checked,reply_enabled:fields.reply_enabled.checked,receiver_user_ids:admins.filter(([,n])=>n.checked).map(([id])=>id)});feedback.textContent='已保存';}catch(e){feedback.textContent=e.message;}finally{save.disabled=false;}});host.append(form); + const bind=el('button','绑定我的 Telegram',buttonClass);bind.type='button';bind.disabled=!data.bot_configured;const command=el('input',null,fieldClass);command.readOnly=true;command.hidden=true;const hint=el('p',null,'text-sm text-muted-foreground');bind.addEventListener('click',async()=>{bind.disabled=true;try{const r=await api('telegramTicketBind',{});command.value=r.command;command.hidden=false;hint.textContent='请在 10 分钟内将这条命令私聊发送给客服机器人。绑定完成后刷新本页;绑定码不要转发。';setTimeout(()=>{if(command.isConnected){command.value='';command.hidden=true;hint.textContent='绑定码已过期,请重新获取。';}},r.expires_in*1000);}catch(e){hint.textContent=e.message;}finally{bind.disabled=false;}});host.append(bind,command,hint); + const counts=data.delivery_counts||{};host.append(el('p','工单推送:待发送 '+(counts.pending||0)+',已发送 '+(counts.sent||0)+',失败 '+(counts.failed||0)+',结果未知 '+(counts.unknown||0)+'。结果未知不会自动重发,避免重复通知。','text-sm text-muted-foreground')); +}catch(e){host.replaceChildren(el('p',e.message,'text-sm text-destructive'));const retry=el('button','重新读取',buttonClass);retry.addEventListener('click',()=>render(host));host.append(retry);}} +let queued=false;function mount(){queued=false;const old=document.getElementById('telegram-ticket-settings');if(!location.hash.startsWith('#/config/system/telegram')){old?.remove();return;}if(old)return; + const heading=[...document.querySelectorAll('h3')].find(n=>n.textContent.trim()==='Telegram设置');const panel=heading?.parentElement?.parentElement;if(!panel)return; + const host=el('section',null,'space-y-4 rounded-xl border bg-card text-card-foreground p-6 mt-6');host.id='telegram-ticket-settings';panel.append(host);render(host); +} +function schedule(){if(!queued){queued=true;requestAnimationFrame(mount);}}new MutationObserver(schedule).observe(document.body,{childList:true,subtree:true});window.addEventListener('hashchange',schedule);schedule(); diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php index f8e7285..6e8610f 100644 --- a/resources/views/admin.blade.php +++ b/resources/views/admin.blade.php @@ -83,6 +83,7 @@
+