Skip to content

Commit c338a5d

Browse files
Merge pull request #169 from acara-app/feature/telegram-photo-analysis
feat: add Telegram photo analysis and attachment support
2 parents dbb0e20 + 2d9eaa5 commit c338a5d

23 files changed

+996
-62
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../.ai/skills/analyse-with-phpstan
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
name: analyse-with-phpstan
3+
description: Analyse PHP code with PHPStan via the playground API. Tests across all PHP versions (7.2–8.5) and reports errors grouped by version. Supports configuring level, strict rules, and bleeding edge.
4+
argument-hint: <php-code-or-file>
5+
disable-model-invocation: false
6+
---
7+
8+
# Analyse PHP code with PHPStan
9+
10+
Analyse PHP code using the PHPStan playground API at `https://api.phpstan.org/analyse`. This runs PHPStan across PHP versions 7.2–8.5 and returns errors for each version.
11+
12+
The code to analyse: `$ARGUMENTS`
13+
14+
## Step 1: Prepare the code
15+
16+
Get the PHP code to analyse. If `$ARGUMENTS` is a file path, read the file contents. The code must start with `<?php`.
17+
18+
## Step 2: Determine settings
19+
20+
Unless the user specified otherwise, use these defaults:
21+
22+
- **level**: `"10"` (strictest)
23+
- **strictRules**: `false`
24+
- **bleedingEdge**: `false`
25+
- **treatPhpDocTypesAsCertain**: `true`
26+
27+
If the user asked for strict rules or bleeding edge, set those to `true`.
28+
29+
## Step 3: Call the playground API
30+
31+
Submit the code via POST:
32+
33+
```bash
34+
curl -s -X POST 'https://api.phpstan.org/analyse' \
35+
-H 'Content-Type: application/json' \
36+
-d '{
37+
"code": "<PHP code, JSON-escaped>",
38+
"level": "<level>",
39+
"strictRules": <true|false>,
40+
"bleedingEdge": <true|false>,
41+
"treatPhpDocTypesAsCertain": <true|false>,
42+
"saveResult": true
43+
}'
44+
```
45+
46+
The code value must be properly JSON-escaped (escape quotes, backslashes, newlines).
47+
48+
## Step 4: Parse the response
49+
50+
The response JSON contains:
51+
52+
- `versionedErrors` — array of objects, one per PHP version, each with:
53+
- `phpVersion` — integer encoding: e.g. `80400` = PHP 8.4, `70400` = PHP 7.4
54+
- `errors` — array of error objects with `message`, `line`, `identifier`, `tip` (optional), `ignorable`
55+
- `id` — UUID for the saved result
56+
57+
Convert `phpVersion` integers to readable strings: `Math.floor(v / 10000)` `.` `Math.floor((v % 10000) / 100)`.
58+
59+
## Step 5: Present results as markdown
60+
61+
Output the results in this format:
62+
63+
### Playground link
64+
65+
`https://phpstan.org/r/<id>`
66+
67+
### Settings used
68+
69+
**Level:** `<level>` | **Strict rules:** yes/no | **Bleeding edge:** yes/no
70+
71+
### Errors
72+
73+
Group consecutive PHP versions that have identical errors (same messages, lines, and identifiers) into ranges. For example, if PHP 7.2–8.3 all report the same errors, show them as one group.
74+
75+
If all PHP versions report identical errors, show a single group:
76+
77+
**All PHP versions (no differences):**
78+
79+
| Line | Error | Identifier |
80+
| ---- | ---------------------------------------------- | --------------- |
81+
| 10 | `Parameter #1 $foo expects string, int given.` | `argument.type` |
82+
83+
If errors differ across versions, show separate groups:
84+
85+
**PHP 8.0 – 8.5:**
86+
87+
| Line | Error | Identifier |
88+
| ---- | ---------------------------------------------- | --------------- |
89+
| 10 | `Parameter #1 $foo expects string, int given.` | `argument.type` |
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions;
6+
7+
use App\Ai\Agents\AssistantAgent;
8+
use App\Http\Requests\StoreAgentConversationRequest;
9+
use App\Models\User;
10+
11+
final class BuildAssistantAgentAction
12+
{
13+
/**
14+
* Resolve and fully configure an AssistantAgent for the given request and user.
15+
*
16+
* Responsibilities:
17+
* - Resolve the agent from the container
18+
* - Wire the agent mode and attachments from the request
19+
* - Conditionally enable web search based on the selected model
20+
*/
21+
public function handle(StoreAgentConversationRequest $request, User $user): AssistantAgent
22+
{
23+
$model = $request->modelName();
24+
$attachments = $request->userAttachments();
25+
26+
$agent = resolve(AssistantAgent::class, ['user' => $user])
27+
->withMode($request->mode())
28+
->withAttachments($attachments)
29+
->forUser($user);
30+
31+
if ($model->supportsWebSearch()) {
32+
$agent->withWebSearch();
33+
}
34+
35+
return $agent;
36+
}
37+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions;
6+
7+
use App\Models\Conversation;
8+
use App\Models\History;
9+
10+
final class BuildConversationMessagesAction
11+
{
12+
/**
13+
* Build the AI SDK message array from a Conversation's History records.
14+
*
15+
* Returns an empty array when no conversation is provided.
16+
*
17+
* @return list<array{id: string, role: string, parts: list<array<string, string>>}>
18+
*/
19+
public function handle(?Conversation $conversation): array
20+
{
21+
if (! $conversation instanceof Conversation) {
22+
return [];
23+
}
24+
25+
return $conversation->messages
26+
->map(fn (History $message): array => [
27+
'id' => $message->id,
28+
'role' => $message->role->value,
29+
'parts' => $this->buildParts($message),
30+
])
31+
->all();
32+
}
33+
34+
/**
35+
* Build the parts array for a single history message.
36+
*
37+
* Always starts with a text part, then appends one part per attachment.
38+
*
39+
* @return list<array<string, string>>
40+
*/
41+
private function buildParts(History $message): array
42+
{
43+
$textPart = ['type' => 'text', 'text' => $message->content];
44+
45+
$attachmentParts = collect($message->attachments ?? [])
46+
->map(function (array $attachment): array { // @phpstan-ignore-line argument.type
47+
$mime = isset($attachment['mime']) && is_string($attachment['mime'])
48+
? $attachment['mime']
49+
: 'image/jpeg';
50+
51+
$base64 = isset($attachment['base64']) && is_string($attachment['base64'])
52+
? $attachment['base64']
53+
: '';
54+
55+
return [
56+
'type' => 'file',
57+
'mediaType' => $mime,
58+
'url' => 'data:'.$mime.';base64,'.$base64,
59+
];
60+
})
61+
->all();
62+
63+
return [$textPart, ...$attachmentParts];
64+
}
65+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions;
6+
7+
use App\Contracts\DownloadsTelegramPhoto;
8+
use DefStudio\Telegraph\DTO\Photo;
9+
use DefStudio\Telegraph\Models\TelegraphBot;
10+
use Illuminate\Support\Facades\Http;
11+
use Laravel\Ai\Files\Base64Image;
12+
use RuntimeException;
13+
14+
final readonly class DownloadTelegramPhotoAction implements DownloadsTelegramPhoto
15+
{
16+
public function handle(TelegraphBot $bot, Photo $photo): Base64Image
17+
{
18+
$telegraph = $bot->getFileInfo($photo->id());
19+
$response = $telegraph->send();
20+
21+
if ($response->telegraphError()) {
22+
throw new RuntimeException('Failed to retrieve file info for Telegram photo: '.$photo->id());
23+
}
24+
25+
/** @var string $filePath */
26+
$filePath = $response->json('result.file_path');
27+
28+
$fileResponse = Http::timeout(30)->get($telegraph->getFilesUrl().'/'.$filePath);
29+
30+
if ($fileResponse->failed()) {
31+
throw new RuntimeException('Failed to download Telegram photo: '.$photo->id());
32+
}
33+
34+
return new Base64Image(
35+
base64_encode($fileResponse->body()),
36+
$this->resolveMimeType($filePath, $fileResponse->header('Content-Type')),
37+
);
38+
}
39+
40+
private function resolveMimeType(string $filePath, ?string $contentType): string
41+
{
42+
if ($contentType !== null && str_starts_with($contentType, 'image/')) {
43+
return $contentType;
44+
}
45+
46+
return match (pathinfo($filePath, PATHINFO_EXTENSION)) {
47+
'png' => 'image/png',
48+
'gif' => 'image/gif',
49+
'webp' => 'image/webp',
50+
default => 'image/jpeg',
51+
};
52+
}
53+
}

app/Actions/ProcessAdvisorMessageAction.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use App\Models\User;
1010
use Illuminate\Support\Facades\Auth;
1111
use Laravel\Ai\Contracts\ConversationStore;
12+
use Laravel\Ai\Files\Base64Image;
1213

1314
final readonly class ProcessAdvisorMessageAction implements ProcessesAdvisorMessage
1415
{
@@ -18,9 +19,10 @@ public function __construct(
1819
) {}
1920

2021
/**
22+
* @param array<int, Base64Image> $attachments
2123
* @return array{response: string, conversation_id: string}
2224
*/
23-
public function handle(User $user, string $message, ?string $conversationId = null): array
25+
public function handle(User $user, string $message, ?string $conversationId = null, array $attachments = []): array
2426
{
2527
// Ensure the user is set in the auth guard so AI tools can access it
2628
// via Auth::user() (Telegram requests bypass web auth middleware).
@@ -29,8 +31,11 @@ public function handle(User $user, string $message, ?string $conversationId = nu
2931
$conversationId ??= $this->conversationStore->latestConversationId($user->id)
3032
?? $this->conversationStore->storeConversation($user->id, 'Telegram Chat');
3133

32-
$agent = $this->advisor->continue($conversationId, $user);
33-
$response = $agent->prompt($message);
34+
$agent = $this->advisor
35+
->withAttachments($attachments)
36+
->continue($conversationId, $user);
37+
38+
$response = $agent->prompt($message, attachments: $attachments);
3439

3540
return [
3641
'response' => $response->text,

app/Ai/Agents/AssistantAgent.php

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use App\Actions\GetUserProfileContextAction;
88
use App\Ai\SystemPrompt;
9+
use App\Ai\Tools\AnalyzePhoto;
910
use App\Ai\Tools\CreateMealPlan;
1011
use App\Ai\Tools\GetDietReference;
1112
use App\Ai\Tools\GetFitnessGoals;
@@ -20,20 +21,31 @@
2021
use App\Enums\AgentMode;
2122
use App\Models\User;
2223
use App\Utilities\LanguageUtil;
24+
use Laravel\Ai\Attributes\Timeout;
2325
use Laravel\Ai\Concerns\RemembersConversations;
2426
use Laravel\Ai\Contracts\Agent;
2527
use Laravel\Ai\Contracts\Conversational;
2628
use Laravel\Ai\Contracts\HasTools;
2729
use Laravel\Ai\Contracts\Tool;
30+
use Laravel\Ai\Files\Base64Image;
2831
use Laravel\Ai\Promptable;
2932
use Laravel\Ai\Providers\Tools\ProviderTool;
33+
use Laravel\Ai\Providers\Tools\WebSearch;
3034

35+
#[Timeout(120)]
3136
final class AssistantAgent implements Agent, Conversational, HasTools
3237
{
3338
use Promptable, RemembersConversations;
3439

3540
private AgentMode $mode = AgentMode::Ask;
3641

42+
/**
43+
* @var array<int, Base64Image>
44+
*/
45+
private array $attachments = [];
46+
47+
private bool $webSearchEnabled = false;
48+
3749
/**
3850
* @var array<int, Tool|ProviderTool>
3951
*/
@@ -51,6 +63,23 @@ public function addTool(Tool|ProviderTool $tool): self
5163
return $this;
5264
}
5365

66+
/**
67+
* @param array<int, Base64Image> $attachments
68+
*/
69+
public function withAttachments(array $attachments): self
70+
{
71+
$this->attachments = $attachments;
72+
73+
return $this;
74+
}
75+
76+
public function withWebSearch(): self
77+
{
78+
$this->webSearchEnabled = true;
79+
80+
return $this;
81+
}
82+
5483
public function withMode(AgentMode $mode): self
5584
{
5685
$this->mode = $mode;
@@ -60,7 +89,8 @@ public function withMode(AgentMode $mode): self
6089

6190
public function instructions(): string
6291
{
63-
$user = $this->conversationUser instanceof User ? $this->conversationUser : $this->user;
92+
$participant = $this->conversationParticipant();
93+
$user = $participant instanceof User ? $participant : $this->user;
6494
$profileData = $this->profileContext->handle($user);
6595

6696
return (string) new SystemPrompt(
@@ -77,7 +107,7 @@ public function instructions(): string
77107
*/
78108
public function tools(): array
79109
{
80-
return array_merge([
110+
$tools = [
81111
new SuggestSingleMeal,
82112
new GetUserProfile,
83113
new CreateMealPlan,
@@ -89,7 +119,17 @@ public function tools(): array
89119
new SuggestWorkoutRoutine,
90120
new GetFitnessGoals,
91121
new GetDietReference,
92-
], $this->additionalTools);
122+
];
123+
124+
if ($this->attachments !== []) {
125+
$tools[] = new AnalyzePhoto($this->attachments);
126+
}
127+
128+
if ($this->webSearchEnabled) {
129+
$tools[] = new WebSearch;
130+
}
131+
132+
return array_merge($tools, $this->additionalTools);
93133
}
94134

95135
/**

0 commit comments

Comments
 (0)