Skip to content

Commit b9a9acb

Browse files
Add OpenAI-based avatar analysis to detect GitHub-generated avatars
- Add AnalyzeUserAvatar job to convert/optimize avatar images, call OpenAI, and store results in user.settings.avatar_analysis - Add DetectGitHubGeneratedAvatars service to batch-dispatch analysis jobs for unanalyzed/unknown avatars - Schedule DetectGitHubGeneratedAvatars weekly in Console Kernel - Mark GitHub-login signups/updates with settings.avatar_analysis.type = "unknown" to queue them for analysis - Change HomeController to prioritize real photos in the avatar section and supplement with unanalyzed/allowed avatars via getUsersForAvatarSection - Add OpenAI config entry and .env.example OPENAI_API_KEY, plus fixture image and comprehensive tests for job, service and avatar filtering - Bump composer deps (laravel/sanctum, nunomaduro/collision) and update composer.lock
1 parent 9991252 commit b9a9acb

14 files changed

Lines changed: 1234 additions & 90 deletions

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ PAGARME_API_KEY=
7272

7373
EMAILOCTOPUS_API_KEY=
7474

75+
OPENAI_API_KEY=
76+
7577
DISCORD_BOT_TOKEN=
7678
DISCORD_GUILD_ID=
7779

app/Console/Kernel.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Console;
44

55
use App\Services\CompareChallengeReadmes;
6+
use App\Services\DetectGitHubGeneratedAvatars;
67
use App\Services\ExpiredPlanService;
78
use App\Services\SyncIsProWithPlans;
89
use Illuminate\Console\Scheduling\Schedule;
@@ -55,6 +56,12 @@ protected function schedule(Schedule $schedule): void
5556
(new \App\Services\OpenClosedChallengeLessonsRobot(new \App\Services\Discord()))->handle();
5657
})
5758
->weeklyOn(1, '05:00');
59+
60+
$schedule
61+
->call(function () {
62+
(new DetectGitHubGeneratedAvatars())->handle();
63+
})
64+
->weeklyOn(1, '05:30');
5865
}
5966

6067
/**

app/Http/Controllers/Auth/GithubLoginController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public function githubLogin(Request $request)
4242
'github_user' => $githubUserData->getNickname(),
4343
'avatar_url' => $githubUserData->getAvatar(),
4444
'email_verified_at' => now(),
45+
'settings' => ['avatar_analysis' => ['type' => 'unknown']],
4546
]);
4647

4748
$isNewSignup = true;
@@ -57,6 +58,11 @@ public function githubLogin(Request $request)
5758
! isset($user->settings['changed_avatar'])
5859
) {
5960
$user->avatar_url = $githubUserData->getAvatar();
61+
62+
// Mark GitHub avatar for analysis by the DetectGitHubGeneratedAvatars service
63+
$settings = $user->settings ?? [];
64+
$settings['avatar_analysis'] = ['type' => 'unknown'];
65+
$user->settings = $settings;
6066
}
6167

6268
$user->github_id = $githubUserData->getId();

app/Http/Controllers/HomeController.php

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ public function index()
2828
'avatar_section' => [
2929
'user_count' => User::count(),
3030
'avatars' => UserAvatarResource::collection(
31-
User::select('avatar_url', 'is_pro', 'is_admin')
32-
->inRandomOrder()
33-
->take(16)
34-
->get()
31+
$this->getUsersForAvatarSection()
3532
),
3633
],
3734
'live_streaming_workshop' => Workshop::where(
@@ -135,6 +132,48 @@ public function index()
135132
});
136133
}
137134

135+
/**
136+
* Get users for avatar section, prioritizing real photos over GitHub-generated avatars.
137+
*/
138+
protected function getUsersForAvatarSection()
139+
{
140+
// First, try to get 16 users with real avatars
141+
$realAvatarUsers = User::whereNotNull('avatar_url')
142+
->whereRaw(
143+
"JSON_UNQUOTE(JSON_EXTRACT(settings, '$.avatar_analysis.type')) = ?",
144+
['real']
145+
)
146+
->select('id', 'avatar_url', 'is_pro', 'is_admin', 'settings')
147+
->inRandomOrder()
148+
->take(16)
149+
->get();
150+
151+
// If we have enough users with real avatars, return them
152+
if ($realAvatarUsers->count() >= 16) {
153+
return $realAvatarUsers;
154+
}
155+
156+
// Otherwise, supplement with unanalyzed users or those without avatars
157+
$remaining = 16 - $realAvatarUsers->count();
158+
159+
$supplementalUsers = User::whereNotNull('avatar_url')
160+
->where(function ($query) {
161+
$query
162+
->whereRaw("JSON_EXTRACT(settings, '$.avatar_analysis') IS NULL")
163+
->orWhereRaw(
164+
"JSON_UNQUOTE(JSON_EXTRACT(settings, '$.avatar_analysis.type')) != ?",
165+
['generated']
166+
);
167+
})
168+
->whereNotIn('id', $realAvatarUsers->pluck('id'))
169+
->select('id', 'avatar_url', 'is_pro', 'is_admin', 'settings')
170+
->inRandomOrder()
171+
->take($remaining)
172+
->get();
173+
174+
return $realAvatarUsers->merge($supplementalUsers);
175+
}
176+
138177
public function sitemap()
139178
{
140179
$itemsArray = Cache::remember('sitemap', 60 * 60, function () {

app/Jobs/AnalyzeUserAvatar.php

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Jobs;
6+
7+
use App\Models\User;
8+
use Illuminate\Bus\Queueable;
9+
use Illuminate\Contracts\Queue\ShouldQueue;
10+
use Illuminate\Foundation\Bus\Dispatchable;
11+
use Illuminate\Queue\InteractsWithQueue;
12+
use Illuminate\Queue\SerializesModels;
13+
use Illuminate\Support\Facades\Http;
14+
use Illuminate\Support\Facades\Log;
15+
16+
final class AnalyzeUserAvatar implements ShouldQueue
17+
{
18+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
19+
20+
private const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
21+
private const OPENAI_MODEL = 'gpt-4o-mini';
22+
23+
/**
24+
* The number of times the job may be attempted.
25+
*/
26+
public int $tries = 3;
27+
28+
/**
29+
* The number of seconds to wait before retrying the job.
30+
*/
31+
public int $backoff = 10;
32+
33+
/**
34+
* The maximum number of seconds the job can run.
35+
*/
36+
public int $timeout = 60;
37+
38+
public function __construct(
39+
private int $userId,
40+
private string $avatarUrl
41+
) {}
42+
43+
public function handle(): void
44+
{
45+
$apiKey = config('services.openai.api_key');
46+
47+
if (empty($apiKey)) {
48+
Log::error('AnalyzeUserAvatar: OpenAI API key not configured', [
49+
'user_id' => $this->userId,
50+
]);
51+
return;
52+
}
53+
54+
try {
55+
// Convert and optimize image before sending to OpenAI
56+
$optimizedImageData = $this->convertAndOptimizeImage($this->avatarUrl);
57+
58+
if ($optimizedImageData === null) {
59+
Log::warning('AnalyzeUserAvatar: Failed to process image', [
60+
'user_id' => $this->userId,
61+
'avatar_url' => $this->avatarUrl,
62+
]);
63+
$this->updateUserSettings(['type' => 'unsupported', 'confidence' => 1.0]);
64+
return;
65+
}
66+
67+
$result = $this->analyzeAvatar($optimizedImageData, $apiKey);
68+
$this->updateUserSettings($result);
69+
70+
Log::info('AnalyzeUserAvatar: Avatar analyzed successfully', [
71+
'user_id' => $this->userId,
72+
'type' => $result['type'],
73+
'confidence' => $result['confidence'],
74+
]);
75+
} catch (\Exception $e) {
76+
Log::error('AnalyzeUserAvatar: Failed to analyze avatar', [
77+
'user_id' => $this->userId,
78+
'avatar_url' => $this->avatarUrl,
79+
'error' => $e->getMessage(),
80+
]);
81+
82+
// Re-throw to trigger retry mechanism
83+
throw $e;
84+
}
85+
}
86+
87+
private function convertAndOptimizeImage(string $url): ?string
88+
{
89+
try {
90+
// Download image
91+
$imageContent = Http::timeout(10)->get($url)->body();
92+
93+
if (empty($imageContent)) {
94+
return null;
95+
}
96+
97+
// Create image from string
98+
$image = imagecreatefromstring($imageContent);
99+
100+
if ($image === false) {
101+
return null;
102+
}
103+
104+
// Get original dimensions
105+
$width = imagesx($image);
106+
$height = imagesy($image);
107+
108+
// Resize to max 256px (reduces tokens significantly)
109+
$maxSize = 256;
110+
if ($width > $maxSize || $height > $maxSize) {
111+
$ratio = min($maxSize / $width, $maxSize / $height);
112+
$newWidth = (int) ($width * $ratio);
113+
$newHeight = (int) ($height * $ratio);
114+
115+
$resized = imagecreatetruecolor($newWidth, $newHeight);
116+
imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
117+
imagedestroy($image);
118+
$image = $resized;
119+
}
120+
121+
// Convert to JPEG with compression
122+
ob_start();
123+
imagejpeg($image, null, 85); // 85% quality
124+
$jpegData = ob_get_clean();
125+
imagedestroy($image);
126+
127+
// Convert to base64 data URL
128+
return 'data:image/jpeg;base64,' . base64_encode($jpegData);
129+
} catch (\Exception $e) {
130+
Log::error('AnalyzeUserAvatar: Failed to convert image', [
131+
'url' => $url,
132+
'error' => $e->getMessage(),
133+
]);
134+
return null;
135+
}
136+
}
137+
138+
private function analyzeAvatar(string $avatarUrl, string $apiKey): array
139+
{
140+
$response = Http::withHeaders([
141+
'Authorization' => 'Bearer ' . $apiKey,
142+
'Content-Type' => 'application/json',
143+
])->timeout(30)->post(self::OPENAI_API_URL, [
144+
'model' => self::OPENAI_MODEL,
145+
'messages' => [
146+
[
147+
'role' => 'user',
148+
'content' => [
149+
[
150+
'type' => 'text',
151+
'text' => 'Analyze this avatar image and determine if it is a real photograph of a person or a GitHub-generated identicon (geometric/pixelated pattern). Provide your assessment with a confidence score.',
152+
],
153+
[
154+
'type' => 'image_url',
155+
'image_url' => [
156+
'url' => $avatarUrl,
157+
],
158+
],
159+
],
160+
],
161+
],
162+
'response_format' => [
163+
'type' => 'json_schema',
164+
'json_schema' => [
165+
'name' => 'avatar_analysis',
166+
'strict' => true,
167+
'schema' => [
168+
'type' => 'object',
169+
'properties' => [
170+
'type' => [
171+
'type' => 'string',
172+
'enum' => ['real', 'generated'],
173+
'description' => 'Whether this is a real photo or a generated identicon',
174+
],
175+
'confidence' => [
176+
'type' => 'number',
177+
'description' => 'Confidence score between 0 and 1',
178+
],
179+
],
180+
'required' => ['type', 'confidence'],
181+
'additionalProperties' => false,
182+
],
183+
],
184+
],
185+
'max_tokens' => 100,
186+
]);
187+
188+
if (! $response->successful()) {
189+
throw new \Exception('OpenAI API request failed: ' . $response->body());
190+
}
191+
192+
$content = $response->json('choices.0.message.content');
193+
194+
// Parse JSON response (guaranteed to be valid JSON with structured outputs)
195+
$result = json_decode($content, true);
196+
197+
if (! is_array($result) || ! isset($result['type']) || ! isset($result['confidence'])) {
198+
throw new \Exception('Invalid response format from OpenAI: ' . $content);
199+
}
200+
201+
return [
202+
'type' => $result['type'],
203+
'confidence' => (float) $result['confidence'],
204+
];
205+
}
206+
207+
private function updateUserSettings(array $analysis): void
208+
{
209+
$user = User::find($this->userId);
210+
211+
if (! $user) {
212+
Log::warning('AnalyzeUserAvatar: User not found', [
213+
'user_id' => $this->userId,
214+
]);
215+
return;
216+
}
217+
218+
$settings = $user->settings ?? [];
219+
$settings['avatar_analysis'] = [
220+
'type' => $analysis['type'],
221+
'confidence' => $analysis['confidence'],
222+
'analyzed_at' => now()->toDateTimeString(),
223+
];
224+
225+
$user->settings = $settings;
226+
$user->save();
227+
}
228+
229+
/**
230+
* Handle a job failure.
231+
*/
232+
public function failed(\Throwable $exception): void
233+
{
234+
Log::error('AnalyzeUserAvatar: Job failed after all retries', [
235+
'user_id' => $this->userId,
236+
'avatar_url' => $this->avatarUrl,
237+
'error' => $exception->getMessage(),
238+
]);
239+
}
240+
}

0 commit comments

Comments
 (0)