Add authenticated Telegram ticket replies and notification tracking

This commit is contained in:
hellcat 2026-10-11 11:58:19 +08:00
parent 9f8f297a5d
commit 6c9422d055
21 changed files with 439 additions and 82 deletions

View File

@ -0,0 +1,12 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SendTelegramTicketJob;
use App\Models\TelegramTicketMessage;
use Illuminate\Console\Command;
class RetryTelegramTickets extends Command {
protected $signature='telegram:tickets:retry';protected $description='Queue pending ticket notifications without resending uncertain deliveries';
public function handle():int {
TelegramTicketMessage::where('status','sending')->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;
}
}

View File

@ -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();

View File

@ -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'],

View File

@ -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,

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\TelegramTicketMessage;
use App\Services\TelegramTickets\TicketBridge;
use Illuminate\Http\Request;
class TelegramTicketController extends Controller {
public function fetch(TicketBridge $bridge){
return $this->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]);
}
}

View File

@ -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']);

View File

@ -0,0 +1,37 @@
<?php
namespace App\Jobs;
use App\Models\TelegramTicketMessage;
use App\Models\User;
use App\Services\TelegramTickets\TicketBridge;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
class SendTelegramTicketJob implements ShouldQueue {
use Dispatchable,InteractsWithQueue,Queueable,SerializesModels;
public int $tries=3;public int $timeout=30;
public function __construct(public int $mappingId){$this->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']);}
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TelegramTicketMessage extends Model {
protected $table='v2_telegram_ticket_messages';protected $guarded=['id'];
protected $casts=['available_at'=>'datetime','chat_id'=>'integer','telegram_message_id'=>'integer','ticket_id'=>'integer','receiver_user_id'=>'integer'];
}

View File

@ -0,0 +1,4 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TelegramTicketUpdate extends Model {protected $table='v2_telegram_ticket_updates';protected $guarded=['id'];}

View File

@ -17,7 +17,7 @@ class TelegramService
public function __construct(?string $token = null)
{
$botToken = admin_setting('telegram_bot_token', $token);
$botToken = $token ?? admin_setting('telegram_bot_token');
$this->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 服务请求失败,请核对机器人配置与网络。");
}
}
}

View File

@ -0,0 +1,80 @@
<?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 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 绑定成功。';
});
});
});
}
}

View File

@ -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();
}
}
}

View File

@ -0,0 +1,19 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up():void {
Schema::create('v2_telegram_ticket_messages',function(Blueprint $t){
$t->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');}
};

View File

@ -18,6 +18,7 @@
| ARCHITECTURE.md | 服务入口、前后端边界、配置来源、开发与检查 |
| BUSINESS_RULES.md | 邮件、提醒、账号与支付等不可随意改变的约束 |
| DEPLOYMENT.md | Git 发布、备份、运行依赖、恢复及故障排查 |
| TELEGRAM_TICKETS.md | Telegram 工单接入、绑定、权限及故障处理 |
| STATUS.md | 最近验证基线、已知限制与待验收项目 |
这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。

View File

@ -45,3 +45,11 @@ Resend 发信、常规 SMTP、配额回退、投递事件回调、master 一次
## 每次维护更新本文件
记录任务、修改文件、测试、提交、是否推送/部署、真实验收结果、尚未完成项与敏感边界。禁止写密码、令牌、原始用户邮件或未经核实的完成结论。
## Telegram 工单实现(2026-10-11)
沿用原有 Telegram 插件与机器人接口,未对接 master。新增工单推送与消息关联、管理员私聊绑定及权限校验、更新编号去重、未知发送结果不自动重试、429 延迟重试和现有邮件通知复用。新增设置直接位于 Telegram 设置页面,无悬浮入口。
本地新迁移执行成功;工单与既有邮件回归合计 68 项测试、375 个断言通过,PHP 与 JavaScript 语法检查通过。浏览器验证新增区域显示、保存、刷新持久化以及切换邮件设置后返回恢复;测试开关已恢复推送开启、回复关闭。详细操作见 TELEGRAM_TICKETS.md。
用户已在线上保存机器人令牌;只读检查确认令牌配置存在,官方 getWebhookInfo 成功,但机器人开关仍关闭、回调地址为空、绑定管理员为零。本节记录时新代码尚未部署,真实工单推送、Telegram 回复和邮件通知闭环尚未验收。

View File

@ -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;不得保存用户原始工单或令牌。

View File

@ -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, '请直接回复机器人发送的新工单提醒消息。');
}
/**

22
public/admin-custom/telegram-tickets.js vendored Normal file
View File

@ -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();

22
resources/js/admin/telegram-tickets.js vendored Normal file
View File

@ -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();

View File

@ -83,6 +83,7 @@
<div id="root"></div>
<script type="module" src="/admin-custom/plan-device-display.js?v={{ filemtime(public_path('admin-custom/plan-device-display.js')) }}"></script>
<script type="module" src="/admin-custom/mail-flow.js?v={{ filemtime(public_path('admin-custom/mail-flow.js')) }}"></script>
<script type="module" src="/admin-custom/telegram-tickets.js?v={{ filemtime(public_path('admin-custom/telegram-tickets.js')) }}"></script>
</body>
</html>

View File

@ -0,0 +1,111 @@
<?php
namespace Tests\Feature\TelegramTickets;
use App\Jobs\SendEmailJob;
use App\Jobs\SendTelegramTicketJob;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Models\TelegramTicketMessage;
use App\Models\TelegramTicketUpdate;
use App\Models\User;
use App\Services\TelegramTickets\TicketBridge;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class TicketBridgeTest extends TestCase {
private User $admin;private User $customer;private Ticket $ticket;private TicketBridge $bridge;
protected function setUp():void {
parent::setUp();config(['database.default'=>'sqlite','database.connections.sqlite.database'=>':memory:','cache.default'=>'array','cache.stores.redis'=>['driver'=>'array'],'queue.default'=>'sync']);
DB::purge('sqlite');Cache::forgetDriver(['array','redis']);$this->app->forgetInstance(\App\Support\Setting::class);$this->app->instance('hook.actions',[]);
Schema::create('v2_settings',fn(Blueprint $t)=>[$t->id(),$t->string('name'),$t->text('value')->nullable(),$t->timestamps()]);
Schema::create('v2_user',function(Blueprint $t){$t->id();$t->string('email');$t->boolean('is_admin')->default(false);$t->boolean('banned')->default(false);$t->boolean('remind_expire')->default(true);$t->integer('plan_id')->nullable();$t->integer('expired_at')->nullable();$t->bigInteger('telegram_id')->nullable();$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();});
Schema::create('v2_ticket',function(Blueprint $t){$t->id();$t->integer('user_id');$t->string('subject');$t->integer('status')->default(0);$t->integer('reply_status')->default(0);$t->integer('last_reply_user_id')->nullable();$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();});
Schema::create('v2_ticket_message',function(Blueprint $t){$t->id();$t->integer('ticket_id');$t->integer('user_id');$t->text('message');$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();});
(require base_path('database/migrations/2026_10_11_140000_create_telegram_ticket_tables.php'))->up();
$this->admin=User::withoutEvents(fn()=>User::create(['email'=>'admin@example.test','is_admin'=>1,'telegram_id'=>123]));
$this->customer=User::withoutEvents(fn()=>User::create(['email'=>'customer@example.test']));
$this->ticket=Ticket::create(['user_id'=>$this->customer->id,'subject'=>'Test']);
$source=TicketMessage::create(['user_id'=>$this->customer->id,'ticket_id'=>$this->ticket->id,'message'=>'Question']);
admin_setting(['telegram_bot_enable'=>true,'telegram_bot_token'=>'synthetic-token','telegram_ticket'=>['notify_enabled'=>true,'reply_enabled'=>true,'receiver_user_ids'=>[]]]);
$this->bridge=app(TicketBridge::class);
TelegramTicketMessage::create(['bot_key'=>$this->bridge->botKey(),'ticket_id'=>$this->ticket->id,'ticket_message_id'=>$source->id,'receiver_user_id'=>$this->admin->id,'chat_id'=>123,'telegram_message_id'=>456,'status'=>'sent']);
Bus::fake([SendEmailJob::class,SendTelegramTicketJob::class]);Http::preventStrayRequests();
}
private function message(array $extra=[]):array{return array_replace_recursive(['chat'=>['id'=>123,'type'=>'private'],'from'=>['id'=>123,'is_bot'=>false],'text'=>'Answer','reply_to_message'=>['message_id'=>456]],$extra);}
public function test_reply_saves_once_and_queues_one_email():void {
$this->assertStringContainsString('回复已保存',$this->bridge->reply($this->message(),1));
$this->assertSame(2,TicketMessage::count());$this->assertSame(1,(int)$this->ticket->fresh()->reply_status);
$this->assertSame($this->admin->id,TicketMessage::orderByDesc('id')->first()->user_id);
$this->assertStringContainsString('已经处理',$this->bridge->reply($this->message(),1));
$this->assertSame(2,TicketMessage::count());Bus::assertDispatched(SendEmailJob::class,1);Http::assertNothingSent();
}
public function test_unauthorized_group_closed_and_unknown_messages_do_not_save():void {
$this->assertStringContainsString('只接受',$this->bridge->reply($this->message(['chat'=>['type'=>'group']]),1));
$this->admin->is_admin=0;$this->admin->save();$this->assertStringContainsString('没有',$this->bridge->reply($this->message(),2));
$this->admin->is_admin=1;$this->admin->save();$this->ticket->status=1;$this->ticket->save();
$this->assertStringContainsString('已关闭',$this->bridge->reply($this->message(),3));
$this->assertNull($this->bridge->reply($this->message(['reply_to_message'=>['message_id'=>999]]),4));
$this->assertSame(1,TicketMessage::count());Bus::assertNotDispatched(SendEmailJob::class);
}
public function test_missing_email_and_cooldown_do_not_prevent_saved_replies():void {
$this->bridge->reply($this->message(),1);$this->bridge->reply($this->message(['text'=>'Second answer']),2);
$this->assertSame(3,TicketMessage::count());Bus::assertDispatched(SendEmailJob::class,1);
$this->customer->email='unbound';$this->customer->save();Cache::flush();
$this->bridge->reply($this->message(['text'=>'No email']),3);$this->assertSame(4,TicketMessage::count());Bus::assertDispatched(SendEmailJob::class,1);
}
public function test_disabled_removed_receiver_and_rotated_bot_cannot_reply():void {
admin_setting(['telegram_ticket'=>['reply_enabled'=>false]]);$this->assertStringContainsString('尚未启用',$this->bridge->reply($this->message(),1));
admin_setting(['telegram_ticket'=>['reply_enabled'=>true,'receiver_user_ids'=>[999]]]);$this->assertStringContainsString('没有',$this->bridge->reply($this->message(),2));
admin_setting(['telegram_bot_token'=>'rotated-token']);$this->assertNull($this->bridge->reply($this->message(),3));$this->assertSame(1,TicketMessage::count());
}
public function test_outbound_send_creates_mapping_and_does_not_resend():void {
$r=TelegramTicketMessage::first();$r->status='pending';$r->telegram_message_id=null;$r->save();
Http::fake(['api.telegram.org/*'=>Http::response(['ok'=>true,'result'=>['message_id'=>789]],200)]);
$job=new SendTelegramTicketJob($r->id);$job->handle($this->bridge);$job->handle($this->bridge);
$this->assertSame('sent',$r->fresh()->status);$this->assertSame(789,$r->fresh()->telegram_message_id);Http::assertSentCount(1);
}
public function test_timeout_is_unknown_and_never_automatically_resubmitted():void {
$r=TelegramTicketMessage::first();$r->status='pending';$r->telegram_message_id=null;$r->save();
Http::fake(['api.telegram.org/*'=>Http::failedConnection()]);$j=new SendTelegramTicketJob($r->id);$j->handle($this->bridge);$j->handle($this->bridge);$this->assertSame('unknown',$r->fresh()->status);
}
public function test_binding_code_is_private_one_time_and_does_not_replace_existing_binding():void {
$code=$this->bridge->bindingCode($this->admin);$this->assertStringContainsString('绑定成功',$this->bridge->bind($this->message(),$code));
$this->assertStringContainsString('无效',$this->bridge->bind($this->message(),$code));
$code=$this->bridge->bindingCode($this->admin);$m=$this->message(['chat'=>['id'=>321],'from'=>['id'=>321]]);
$this->assertStringContainsString('已绑定其他',$this->bridge->bind($m,$code));$this->assertSame(123,(int)$this->admin->fresh()->telegram_id);
}
public function test_enqueuing_same_source_only_creates_one_record_per_admin():void {
$this->bridge->enqueue($this->ticket);$this->bridge->enqueue($this->ticket);$this->assertSame(1,TelegramTicketMessage::count());
}
public function test_rate_limit_waits_until_retry_after_before_sending_again():void {
$r=TelegramTicketMessage::first();$r->update(['status'=>'pending','telegram_message_id'=>null]);
Http::fake(['api.telegram.org/*'=>Http::response(['ok'=>false,'parameters'=>['retry_after'=>3600]],429)]);
$job=new SendTelegramTicketJob($r->id);$job->handle($this->bridge);$job->handle($this->bridge);
$this->assertSame('pending',$r->fresh()->status);$this->assertTrue($r->fresh()->available_at->isFuture());Http::assertSentCount(1);
}
public function test_spoofed_sender_and_other_users_binding_are_rejected():void {
$this->assertStringContainsString('只接受',$this->bridge->reply($this->message(['from'=>['id'=>999]]),1));
$this->customer->telegram_id=999;$this->customer->save();$code=$this->bridge->bindingCode($this->admin);
$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_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)]);
admin_setting(['app_url'=>'https://example.test']);
$controller=app(\App\Http\Controllers\V2\Admin\ConfigController::class);
$response=$controller->setTelegramWebhook(\Illuminate\Http\Request::create('/','POST'));
$this->assertSame(422,$response->getStatusCode());Http::assertSentCount(2);
admin_setting(['telegram_webhook_secret'=>'testsecret']);
$request=\Illuminate\Http\Request::create('/','POST',[],[],[],['CONTENT_TYPE'=>'application/json'],'{}');
try{app(\App\Http\Controllers\V1\Guest\TelegramController::class)->webhook($request);$this->fail('Missing secret must be rejected');}catch(\App\Exceptions\ApiException $e){$this->assertTrue(true);}
$request->headers->set('X-Telegram-Bot-Api-Secret-Token','testsecret');
app(\App\Http\Controllers\V1\Guest\TelegramController::class)->webhook($request);
$this->assertSame(1,TicketMessage::count());
}
}