Add bnfexpress signed admin client and AI Agent Filament UI
- Modules\Shared\Bnfexpress\BnfexpressAdminClient: HMAC-signed HTTP client for bnfexpress's admin API (EV FAQs, agent instructions, chat history), with a bnfexpress:smoke-test command and full unit coverage. - New ai-agent module: Filament pages to manage EV FAQs, publish/roll back agent instruction versions, and browse EV chat history + transcripts. - New manage_ai_agent permission (super_admin/admin). - Recorded .ai/rules for the client's auth scheme and non-Resource Filament page/table testing gotchas.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "modules/ai-agent",
|
||||
"description": "",
|
||||
"type": "library",
|
||||
"version": "1.0",
|
||||
"license": "proprietary",
|
||||
"require": {},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\AiAgent\\": "src/",
|
||||
"Modules\\AiAgent\\Tests\\": "tests/",
|
||||
"Modules\\AiAgent\\Database\\Factories\\": "database/factories/",
|
||||
"Modules\\AiAgent\\Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Modules\\AiAgent\\Providers\\AiAgentServiceProvider"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section heading="Active Instruction">
|
||||
@if ($active)
|
||||
<pre class="whitespace-pre-wrap text-sm">{{ $active['content'] ?? '' }}</pre>
|
||||
@elseif ($activeError)
|
||||
<p class="text-sm text-danger-600">Could not load the active instruction: {{ $activeError }}</p>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">No active instruction.</p>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<p class="text-sm text-danger-600">Could not load this transcript: {{ $message }}</p>
|
||||
@@ -0,0 +1,34 @@
|
||||
@php
|
||||
$labels = [
|
||||
'user' => 'User',
|
||||
'bnfexpress_ev_agent' => 'Assistant',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="max-h-[32rem] space-y-3 overflow-y-auto">
|
||||
@forelse (($transcript['messages'] ?? []) as $message)
|
||||
@php
|
||||
$author = $message['author'] ?? 'unknown';
|
||||
$isUser = $author === 'user';
|
||||
@endphp
|
||||
<div @class([
|
||||
'max-w-[85%] rounded-lg border p-3',
|
||||
'ms-auto border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-950' => $isUser,
|
||||
'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800' => ! $isUser,
|
||||
])>
|
||||
<div class="mb-1 flex items-center justify-between gap-3">
|
||||
<span class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ $labels[$author] ?? $author }}
|
||||
</span>
|
||||
@if (isset($message['timestamp']))
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ \Illuminate\Support\Carbon::createFromTimestamp($message['timestamp'])->format('M j, Y g:i A') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap text-sm">{{ $message['text'] ?? '' }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-500">No messages in this session.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class AiAgentPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'ai-agent';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel->discoverPages(
|
||||
in: __DIR__.'/Filament/Pages',
|
||||
for: 'Modules\AiAgent\Filament\Pages',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Concerns;
|
||||
|
||||
use Filament\Notifications\Notification;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
|
||||
/**
|
||||
* Shared try/call/notify wrapper for bnfexpress admin API calls triggered
|
||||
* from a Filament action — every mutating action across the AI Agent pages
|
||||
* (create/update/delete/publish/activate) follows this same shape.
|
||||
*/
|
||||
trait HandlesBnfexpressErrors
|
||||
{
|
||||
/**
|
||||
* @param callable(): void $callback
|
||||
*/
|
||||
protected function callBnfexpress(callable $callback, string $successTitle, string $failureTitle): void
|
||||
{
|
||||
try {
|
||||
$callback();
|
||||
|
||||
Notification::make()->title($successTitle)->success()->send();
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Version history + publish/rollback for the EV agent's system prompt.
|
||||
* Needs more than a bare table (an "active version" banner above the
|
||||
* history), so — like ManageAppSettings — it renders through a custom view
|
||||
* rather than relying purely on Filament's generated table layout.
|
||||
*/
|
||||
class ManageAgentInstructions extends Page implements HasTable
|
||||
{
|
||||
use HandlesBnfexpressErrors;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||
|
||||
protected static ?string $navigationLabel = 'Agent Instructions';
|
||||
|
||||
protected static ?string $title = 'Agent Instructions';
|
||||
|
||||
protected string $view = 'ai-agent::filament.pages.manage-agent-instructions';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $active = null;
|
||||
|
||||
public ?string $activeError = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refreshActive();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||
$result = app(BnfexpressAdminClient::class)->listInstructions(
|
||||
limit: $recordsPerPage,
|
||||
offset: ($page - 1) * $recordsPerPage,
|
||||
);
|
||||
|
||||
$items = $result['instructions'] ?? (array_is_list($result) ? $result : []);
|
||||
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
|
||||
total: $total,
|
||||
perPage: $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id'),
|
||||
IconColumn::make('is_active')->boolean(),
|
||||
TextColumn::make('content')->limit(80)->wrap(),
|
||||
TextColumn::make('created_at')->dateTime(),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('activate')
|
||||
->label('Activate')
|
||||
->icon(Heroicon::OutlinedArrowUturnLeft)
|
||||
->visible(fn (array $record): bool => ! ($record['is_active'] ?? false))
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->activateInstruction($record['id']),
|
||||
successTitle: 'Instruction activated',
|
||||
failureTitle: 'Failed to activate instruction',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
$this->refreshActive();
|
||||
}),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('publish')
|
||||
->label('Publish New Version')
|
||||
->icon(Heroicon::OutlinedPlusCircle)
|
||||
->schema([
|
||||
Textarea::make('content')
|
||||
->required()
|
||||
->rows(10),
|
||||
Toggle::make('activate')
|
||||
->label('Activate immediately')
|
||||
->default(true)
|
||||
->helperText('Deactivates the current active version automatically.'),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->publishInstruction($data['content'], $data['activate']),
|
||||
successTitle: 'New instruction version published',
|
||||
failureTitle: 'Failed to publish instruction',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
$this->refreshActive();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private function refreshActive(): void
|
||||
{
|
||||
try {
|
||||
$this->active = app(BnfexpressAdminClient::class)->getActiveInstruction();
|
||||
$this->activeError = null;
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
$this->active = null;
|
||||
$this->activeError = $exception->getMessage();
|
||||
|
||||
Notification::make()
|
||||
->title('Could not load the active instruction')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Manage EV FAQs stored by bnfexpress — data isn't Eloquent-backed, so the
|
||||
* table is fed via Table::records() (Filament's documented "custom data"
|
||||
* mechanism) rather than a query, and row/header actions use plain
|
||||
* Filament\Actions\Action instead of EditAction/DeleteAction (which assume
|
||||
* a Model).
|
||||
*/
|
||||
class ManageFaqs extends Page implements HasTable
|
||||
{
|
||||
use HandlesBnfexpressErrors;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQuestionMarkCircle;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||
|
||||
protected static ?string $navigationLabel = 'EV FAQs';
|
||||
|
||||
protected static ?string $title = 'EV FAQs';
|
||||
|
||||
protected string $view = 'ai-agent::filament.pages.manage-faqs';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(function (?string $search, array $filters, int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||
$result = app(BnfexpressAdminClient::class)->listFaqs(
|
||||
q: $search,
|
||||
search: $filters['search_mode']['value'] ?? 'normal',
|
||||
limit: $recordsPerPage,
|
||||
offset: ($page - 1) * $recordsPerPage,
|
||||
);
|
||||
|
||||
return $this->paginate($result, 'faqs', $page, $recordsPerPage);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id'),
|
||||
TextColumn::make('content')
|
||||
->limit(80)
|
||||
->wrap(),
|
||||
TextColumn::make('metadata')
|
||||
->formatStateUsing(fn (mixed $state): string => json_encode($state ?? [], JSON_THROW_ON_ERROR))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->searchable()
|
||||
->filters([
|
||||
SelectFilter::make('search_mode')
|
||||
->label('Search mode')
|
||||
->options([
|
||||
'normal' => 'Normal (substring)',
|
||||
'semantic' => 'Semantic (meaning-based)',
|
||||
])
|
||||
->default('normal'),
|
||||
])
|
||||
->recordActions([
|
||||
$this->editAction(),
|
||||
$this->deleteAction(),
|
||||
])
|
||||
->headerActions([
|
||||
$this->createAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createAction(): Action
|
||||
{
|
||||
return Action::make('create')
|
||||
->label('New FAQ')
|
||||
->icon(Heroicon::OutlinedPlus)
|
||||
->schema($this->formSchema())
|
||||
->action(function (array $data): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->createFaq($data['content'], $data['metadata'] ?? []),
|
||||
successTitle: 'FAQ created',
|
||||
failureTitle: 'Failed to create FAQ',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function editAction(): Action
|
||||
{
|
||||
return Action::make('edit')
|
||||
->icon(Heroicon::OutlinedPencilSquare)
|
||||
->fillForm(fn (array $record): array => $record)
|
||||
->schema($this->formSchema())
|
||||
->action(function (array $data, array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->updateFaq($record['id'], $data['content'], $data['metadata'] ?? []),
|
||||
successTitle: 'FAQ updated',
|
||||
failureTitle: 'Failed to update FAQ',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function deleteAction(): Action
|
||||
{
|
||||
return Action::make('delete')
|
||||
->color('danger')
|
||||
->icon(Heroicon::OutlinedTrash)
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
$this->callBnfexpress(
|
||||
fn () => app(BnfexpressAdminClient::class)->deleteFaq($record['id']),
|
||||
successTitle: 'FAQ deleted',
|
||||
failureTitle: 'Failed to delete FAQ',
|
||||
);
|
||||
|
||||
$this->resetTable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Component>
|
||||
*/
|
||||
protected function formSchema(): array
|
||||
{
|
||||
return [
|
||||
Textarea::make('content')
|
||||
->required()
|
||||
->rows(4),
|
||||
KeyValue::make('metadata'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* bnfexpress's list/faqs and list/agent-instructions endpoints return a
|
||||
* bare JSON array (confirmed live via `php artisan bnfexpress:smoke-test`),
|
||||
* with no total/limit/offset envelope — so there's no real total to
|
||||
* report, and this falls back to a "there might be one more page"
|
||||
* heuristic (also tolerates a {total, <$itemsKey>} envelope, in case
|
||||
* that ever changes).
|
||||
*
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
private function paginate(array $result, string $itemsKey, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
$items = $result[$itemsKey] ?? (array_is_list($result) ? $result : []);
|
||||
|
||||
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
|
||||
total: $total,
|
||||
perPage: $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Read-only browse of all users' EV chat sessions. No create/edit/delete —
|
||||
* this is a support/QA tool, not a data-management screen.
|
||||
*/
|
||||
class ViewEvChatHistory extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||
|
||||
protected static ?string $navigationLabel = 'EV Chat History';
|
||||
|
||||
protected static ?string $title = 'EV Chat History';
|
||||
|
||||
protected string $view = 'ai-agent::filament.pages.view-ev-chat-history';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||
$result = app(BnfexpressAdminClient::class)->listSessions(
|
||||
limit: $recordsPerPage,
|
||||
offset: ($page - 1) * $recordsPerPage,
|
||||
);
|
||||
|
||||
$sessions = $result['sessions'] ?? [];
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
items: collect($sessions)->mapWithKeys(
|
||||
fn (array $session): array => ["{$session['user_id']}:{$session['session_id']}" => $session]
|
||||
),
|
||||
total: $result['total'] ?? count($sessions),
|
||||
perPage: $result['limit'] ?? $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('session_id')
|
||||
->limit(20)
|
||||
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
|
||||
TextColumn::make('user_id')
|
||||
->limit(20)
|
||||
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
|
||||
TextColumn::make('title')->limit(50),
|
||||
TextColumn::make('last_message')->limit(80)->wrap(),
|
||||
TextColumn::make('updated_at')->dateTime(),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('view')
|
||||
->label('View Transcript')
|
||||
->icon(Heroicon::OutlinedEye)
|
||||
->modalHeading(fn (array $record): string => "Transcript — {$record['session_id']}")
|
||||
->modalContent(fn (array $record): View => $this->transcriptView($record))
|
||||
->modalWidth('2xl')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function tooltipIfTruncated(TextColumn $column): ?string
|
||||
{
|
||||
$state = (string) $column->getState();
|
||||
|
||||
return strlen($state) > $column->getCharacterLimit() ? $state : null;
|
||||
}
|
||||
|
||||
private function transcriptView(array $record): View
|
||||
{
|
||||
try {
|
||||
$transcript = app(BnfexpressAdminClient::class)->getSessionTranscript($record['user_id'], $record['session_id']);
|
||||
|
||||
return view('ai-agent::filament.pages.partials.transcript', ['transcript' => $transcript]);
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
Notification::make()
|
||||
->title('Could not load transcript')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return view('ai-agent::filament.pages.partials.transcript-error', ['message' => $exception->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\AiAgent\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AiAgentServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void {}
|
||||
|
||||
public function boot(): void {}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ManageAgentInstructions;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ManageAgentInstructions::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it renders the active instruction and version history', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'You are the EV assistant.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => Http::response([
|
||||
'total' => 1,
|
||||
'instructions' => [['id' => 2, 'is_active' => true, 'content' => 'You are the EV assistant.', 'created_at' => now()->toIso8601String()]],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->assertOk()
|
||||
->assertSee('You are the EV assistant.')
|
||||
->loadTable()
|
||||
->assertSee('You are the EV assistant.');
|
||||
});
|
||||
|
||||
test('publishing a new version calls publishInstruction and shows a success notification', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 3, 'content' => 'New instructions.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['id' => 3, 'content' => 'New instructions.', 'is_active' => true], 201),
|
||||
default => Http::response(['total' => 0, 'instructions' => []]),
|
||||
},
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->loadTable()
|
||||
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
|
||||
->assertNotified('New instruction version published');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
|
||||
&& str_contains($request->url(), '/admin/agent-instructions')
|
||||
&& ! str_contains($request->url(), '/active')
|
||||
&& $request['content'] === 'New instructions.'
|
||||
&& $request['activate'] === true);
|
||||
});
|
||||
|
||||
test('a failed publish surfaces the gateway detail message', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 1, 'content' => 'Old instructions.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['detail' => 'Content is required.'], 422),
|
||||
default => Http::response(['total' => 0, 'instructions' => []]),
|
||||
},
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->loadTable()
|
||||
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
|
||||
->assertNotified('Failed to publish instruction');
|
||||
});
|
||||
|
||||
test('activate is hidden on the already-active row and visible on others', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'Current.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => Http::response([
|
||||
'total' => 2,
|
||||
'instructions' => [
|
||||
['id' => 2, 'is_active' => true, 'content' => 'Current.', 'created_at' => now()->toIso8601String()],
|
||||
['id' => 1, 'is_active' => false, 'content' => 'Older.', 'created_at' => now()->toIso8601String()],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->loadTable()
|
||||
->assertTableActionHidden('activate', 2)
|
||||
->assertTableActionVisible('activate', 1);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ManageFaqs;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ManageFaqs::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it renders and lists faqs from the client', function () {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response([
|
||||
'total' => 1,
|
||||
'faqs' => [
|
||||
['id' => 1, 'content' => 'How do I charge my EV?', 'metadata' => []],
|
||||
],
|
||||
])]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->assertOk()
|
||||
->loadTable()
|
||||
->assertSee('How do I charge my EV?');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/faqs') && $request->method() === 'GET');
|
||||
});
|
||||
|
||||
test('creating a faq calls createFaq and shows a success notification', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['id' => 1, 'content' => 'New FAQ', 'metadata' => []], 201),
|
||||
default => Http::response(['total' => 0, 'faqs' => []]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->loadTable()
|
||||
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
|
||||
->assertNotified('FAQ created');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'POST' && $request['content'] === 'New FAQ');
|
||||
});
|
||||
|
||||
test('a failed create surfaces the gateway detail message', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['detail' => 'Content is required.'], 422),
|
||||
default => Http::response(['total' => 0, 'faqs' => []]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->loadTable()
|
||||
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
|
||||
->assertNotified('Failed to create FAQ');
|
||||
});
|
||||
|
||||
test('deleting a faq calls deleteFaq and shows a success notification', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'DELETE' => Http::response(['deleted' => true]),
|
||||
default => Http::response(['total' => 1, 'faqs' => [['id' => 1, 'content' => 'To delete', 'metadata' => []]]]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->loadTable()
|
||||
->callTableAction('delete', 1)
|
||||
->assertNotified('FAQ deleted');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/faqs/1'));
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ViewEvChatHistory;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ViewEvChatHistory::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it lists sessions from the client', function () {
|
||||
Http::fake(['bnfexpress.test/admin/ev/history*' => Http::response([
|
||||
'total' => 1,
|
||||
'limit' => 25,
|
||||
'offset' => 0,
|
||||
'sessions' => [
|
||||
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||
],
|
||||
])]);
|
||||
|
||||
Livewire::test(ViewEvChatHistory::class)
|
||||
->assertOk()
|
||||
->loadTable()
|
||||
->assertSee('Charging question');
|
||||
});
|
||||
|
||||
test('viewing a transcript shows its messages', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response([
|
||||
'session_id' => 'sess-1',
|
||||
'user_id' => 'user-1',
|
||||
'messages' => [
|
||||
['author' => 'user', 'text' => 'How do I charge my EV?', 'timestamp' => now()->timestamp],
|
||||
['author' => 'bnfexpress_ev_agent', 'text' => 'Plug it in at a station.', 'timestamp' => now()->timestamp],
|
||||
],
|
||||
]),
|
||||
'bnfexpress.test/admin/ev/history*' => Http::response([
|
||||
'total' => 1,
|
||||
'limit' => 25,
|
||||
'offset' => 0,
|
||||
'sessions' => [
|
||||
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ViewEvChatHistory::class)
|
||||
->loadTable()
|
||||
->mountTableAction('view', 'user-1:sess-1')
|
||||
->assertMountedActionModalSee('Plug it in at a station.');
|
||||
});
|
||||
|
||||
test('a failed transcript fetch surfaces the gateway detail message', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response(['detail' => 'Session not found.'], 404),
|
||||
'bnfexpress.test/admin/ev/history*' => Http::response([
|
||||
'total' => 1,
|
||||
'limit' => 25,
|
||||
'offset' => 0,
|
||||
'sessions' => [
|
||||
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ViewEvChatHistory::class)
|
||||
->loadTable()
|
||||
->mountTableAction('view', 'user-1:sess-1')
|
||||
->assertMountedActionModalSee('Session not found.')
|
||||
->assertNotified('Could not load transcript');
|
||||
});
|
||||
@@ -26,6 +26,7 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
'manage_ai_agent',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -46,6 +47,7 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
'manage_ai_agent',
|
||||
],
|
||||
'admin' => [
|
||||
'manage_catalog',
|
||||
@@ -59,6 +61,7 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
'manage_ai_agent',
|
||||
],
|
||||
'support' => [
|
||||
'view_bookings',
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Shared\Bnfexpress;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
use Modules\Shared\Bnfexpress\Support\BnfexpressSignature;
|
||||
|
||||
/**
|
||||
* Signed HTTP client for bnfexpress's admin APIs — EV FAQs, agent
|
||||
* instruction versions, and read-only EV chat history. Backend-to-backend
|
||||
* auth only (no user session/JWT): every request is signed per
|
||||
* BnfexpressSignature (config('services.bnfexpress')).
|
||||
*/
|
||||
class BnfexpressAdminClient
|
||||
{
|
||||
private const AGENT = 'ev';
|
||||
|
||||
private readonly string $baseUrl;
|
||||
|
||||
private readonly string $clientId;
|
||||
|
||||
private readonly string $secret;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $config
|
||||
*/
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$config ??= (array) config('services.bnfexpress');
|
||||
|
||||
$this->baseUrl = rtrim((string) ($config['ai_api_url'] ?? ''), '/');
|
||||
$this->clientId = (string) ($config['client_id'] ?? '');
|
||||
$this->secret = (string) ($config['client_secret'] ?? '');
|
||||
}
|
||||
|
||||
// --- FAQs ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function listFaqs(?string $q = null, string $search = 'normal', ?int $limit = null, ?int $offset = null): array
|
||||
{
|
||||
return $this->request('GET', '/admin/faqs', query: array_filter([
|
||||
'agent' => self::AGENT,
|
||||
'q' => $q,
|
||||
// Ignored by bnfexpress when q is empty, but only sent when q is set.
|
||||
'search' => ($q !== null && $q !== '') ? $search : null,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
], fn (mixed $value): bool => $value !== null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getFaq(int|string $id): array
|
||||
{
|
||||
return $this->request('GET', "/admin/faqs/{$id}");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createFaq(string $content, array $metadata = []): array
|
||||
{
|
||||
return $this->request('POST', '/admin/faqs', body: [
|
||||
'content' => $content,
|
||||
'agent' => self::AGENT,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $metadata
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateFaq(int|string $id, ?string $content = null, ?array $metadata = null): array
|
||||
{
|
||||
return $this->request('PATCH', "/admin/faqs/{$id}", body: array_filter([
|
||||
'content' => $content,
|
||||
'metadata' => $metadata,
|
||||
], fn (mixed $value): bool => $value !== null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function deleteFaq(int|string $id): array
|
||||
{
|
||||
return $this->request('DELETE', "/admin/faqs/{$id}");
|
||||
}
|
||||
|
||||
// --- Agent instructions --------------------------------------------
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function listInstructions(?int $limit = null, ?int $offset = null): array
|
||||
{
|
||||
return $this->request('GET', '/admin/agent-instructions', query: array_filter([
|
||||
'agent' => self::AGENT,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
], fn (mixed $value): bool => $value !== null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getActiveInstruction(): array
|
||||
{
|
||||
return $this->request('GET', '/admin/agent-instructions/active', query: [
|
||||
'agent' => self::AGENT,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a new instruction version. Setting $activate (default true)
|
||||
* deactivates the previously active version automatically, server-side.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function publishInstruction(string $content, bool $activate = true): array
|
||||
{
|
||||
return $this->request('POST', '/admin/agent-instructions', body: [
|
||||
'agent' => self::AGENT,
|
||||
'content' => $content,
|
||||
'activate' => $activate,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls back to an older instruction version.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function activateInstruction(int|string $id): array
|
||||
{
|
||||
return $this->request('POST', "/admin/agent-instructions/{$id}/activate");
|
||||
}
|
||||
|
||||
// --- EV chat history (read-only) ------------------------------------
|
||||
|
||||
/**
|
||||
* @return array{total: int, limit: int, offset: int, sessions: list<array<string, mixed>>}
|
||||
*/
|
||||
public function listSessions(?int $limit = null, ?int $offset = null): array
|
||||
{
|
||||
return $this->request('GET', '/admin/ev/history', query: array_filter([
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
], fn (mixed $value): bool => $value !== null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSessionTranscript(string $userId, string $sessionId): array
|
||||
{
|
||||
return $this->request('GET', "/admin/ev/history/{$userId}/{$sessionId}");
|
||||
}
|
||||
|
||||
// --- Request plumbing ------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
* @param array<string, mixed>|null $body
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function request(string $method, string $path, array $query = [], ?array $body = null): array
|
||||
{
|
||||
// Signed over exactly these bytes — must match what's actually sent,
|
||||
// so it's built once and reused for both the signature and the body.
|
||||
$rawBody = $body !== null ? json_encode($body, JSON_THROW_ON_ERROR) : '';
|
||||
|
||||
$headers = BnfexpressSignature::headers($method, $path, $rawBody, $this->clientId, $this->secret);
|
||||
|
||||
$pending = Http::baseUrl($this->baseUrl)->withHeaders($headers);
|
||||
|
||||
try {
|
||||
$response = match ($method) {
|
||||
'GET' => $pending->get($path, $query),
|
||||
'DELETE' => $pending->delete($path, $query),
|
||||
'POST' => $pending->withBody($rawBody, 'application/json')->post($path),
|
||||
'PATCH' => $pending->withBody($rawBody, 'application/json')->patch($path),
|
||||
default => throw new \InvalidArgumentException("Unsupported HTTP method [{$method}]."),
|
||||
};
|
||||
} catch (ConnectionException $exception) {
|
||||
throw new BnfexpressApiException($exception->getMessage());
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new BnfexpressApiException(
|
||||
(string) ($response->json('detail') ?? "bnfexpress request failed with status {$response->status()}."),
|
||||
$response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return (array) $response->json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Shared\Bnfexpress\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown when bnfexpress's admin API returns a non-2xx response or the
|
||||
* request fails to connect. Carries the gateway's own {"detail": "..."}
|
||||
* message (falling back to a generic one) rather than a bare status code.
|
||||
*/
|
||||
class BnfexpressApiException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly int $status = 0)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Shared\Bnfexpress\Support;
|
||||
|
||||
/**
|
||||
* bnfexpress's backend-to-backend admin auth scheme: every request carries
|
||||
* X-Client-Id/X-Timestamp/X-Signature, where the signature is a hex HMAC-SHA256
|
||||
* over "{METHOD}\n{PATH}\n{TIMESTAMP}\n{RAW_BODY}" (uppercase verb, path only —
|
||||
* no scheme/host/query — and the exact raw JSON bytes being sent, or "" for a
|
||||
* bodyless request). Timestamps are generated fresh per call — bnfexpress
|
||||
* rejects anything more than 300s from server time — so headers() must never
|
||||
* be memoized/reused across requests.
|
||||
*/
|
||||
class BnfexpressSignature
|
||||
{
|
||||
/**
|
||||
* @param int|null $timestamp Overrides the current time; only ever passed in tests.
|
||||
* @return array{'X-Client-Id': string, 'X-Timestamp': string, 'X-Signature': string}
|
||||
*/
|
||||
public static function headers(string $method, string $path, string $rawBody, string $clientId, string $secret, ?int $timestamp = null): array
|
||||
{
|
||||
$timestamp = (string) ($timestamp ?? time());
|
||||
|
||||
$payload = strtoupper($method)."\n".$path."\n".$timestamp."\n".$rawBody;
|
||||
|
||||
return [
|
||||
'X-Client-Id' => $clientId,
|
||||
'X-Timestamp' => $timestamp,
|
||||
'X-Signature' => hash_hmac('sha256', $payload, $secret),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Shared\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
|
||||
/**
|
||||
* Exercises the signed bnfexpress admin client end to end (list FAQs, get
|
||||
* the active instruction) against the real BNFEXPRESS_AI_API_URL, to confirm
|
||||
* request signing checks out before any UI is wired up to it.
|
||||
*/
|
||||
class BnfexpressSmokeTestCommand extends Command
|
||||
{
|
||||
protected $signature = 'bnfexpress:smoke-test';
|
||||
|
||||
protected $description = 'Call bnfexpress\'s admin API (list EV FAQs, get the active EV instruction) to verify request signing';
|
||||
|
||||
public function handle(BnfexpressAdminClient $client): int
|
||||
{
|
||||
try {
|
||||
$this->components->task('GET /admin/faqs?agent=ev', function () use ($client) {
|
||||
$faqs = $client->listFaqs();
|
||||
$this->line(' '.json_encode($faqs));
|
||||
});
|
||||
|
||||
$this->components->task('GET /admin/agent-instructions/active?agent=ev', function () use ($client) {
|
||||
$active = $client->getActiveInstruction();
|
||||
$this->line(' '.json_encode($active));
|
||||
});
|
||||
} catch (BnfexpressApiException $exception) {
|
||||
$this->components->error('bnfexpress request failed: '.$exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->components->info('bnfexpress signing verified.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
|
||||
$config = [
|
||||
'ai_api_url' => 'https://bnfexpress.test',
|
||||
'client_id' => 'ev_admin',
|
||||
'client_secret' => 'shared-secret',
|
||||
];
|
||||
|
||||
function assertSignedCorrectly($request, string $method, string $path, string $rawBody, string $secret): bool
|
||||
{
|
||||
$timestamp = $request->header('X-Timestamp')[0] ?? null;
|
||||
$expected = hash_hmac('sha256', strtoupper($method)."\n".$path."\n".$timestamp."\n".$rawBody, $secret);
|
||||
|
||||
return $request->hasHeader('X-Client-Id', 'ev_admin')
|
||||
&& $timestamp !== null
|
||||
&& abs(time() - (int) $timestamp) < 5
|
||||
&& $request->header('X-Signature')[0] === $expected;
|
||||
}
|
||||
|
||||
test('listFaqs signs a GET request and excludes the query string from the signed path', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->listFaqs(q: 'range', search: 'semantic', limit: 10, offset: 0);
|
||||
|
||||
Http::assertSent(function ($request) use ($config) {
|
||||
return $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev&q=range&search=semantic&limit=10&offset=0'
|
||||
&& assertSignedCorrectly($request, 'GET', '/admin/faqs', '', $config['client_secret']);
|
||||
});
|
||||
});
|
||||
|
||||
test('listFaqs omits search when q is empty', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->listFaqs();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev');
|
||||
});
|
||||
|
||||
test('createFaq signs the exact raw JSON body being sent', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['id' => 1], 201)]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->createFaq('How do I charge?', ['source' => 'admin']);
|
||||
|
||||
$rawBody = json_encode([
|
||||
'content' => 'How do I charge?',
|
||||
'agent' => 'ev',
|
||||
'metadata' => ['source' => 'admin'],
|
||||
]);
|
||||
|
||||
Http::assertSent(function ($request) use ($config, $rawBody) {
|
||||
return $request->url() === 'https://bnfexpress.test/admin/faqs'
|
||||
&& $request->method() === 'POST'
|
||||
&& $request->body() === $rawBody
|
||||
&& assertSignedCorrectly($request, 'POST', '/admin/faqs', $rawBody, $config['client_secret']);
|
||||
});
|
||||
});
|
||||
|
||||
test('getActiveInstruction requests the active instruction for the ev agent', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['content' => 'You are the EV assistant.'])]);
|
||||
|
||||
$result = (new BnfexpressAdminClient($config))->getActiveInstruction();
|
||||
|
||||
expect($result)->toBe(['content' => 'You are the EV assistant.']);
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/agent-instructions/active?agent=ev');
|
||||
});
|
||||
|
||||
test('a non-2xx response surfaces the gateway detail message', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['detail' => 'FAQ not found.'], 404)]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->getFaq(999);
|
||||
})->throws(BnfexpressApiException::class, 'FAQ not found.');
|
||||
|
||||
test('deleteFaq signs a bodyless DELETE request', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['deleted' => true])]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->deleteFaq(5);
|
||||
|
||||
Http::assertSent(function ($request) use ($config) {
|
||||
return $request->url() === 'https://bnfexpress.test/admin/faqs/5'
|
||||
&& $request->method() === 'DELETE'
|
||||
&& assertSignedCorrectly($request, 'DELETE', '/admin/faqs/5', '', $config['client_secret']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Modules\Shared\Bnfexpress\Support\BnfexpressSignature;
|
||||
|
||||
test('headers computes the hex HMAC-SHA256 over METHOD\PATH\TIMESTAMP\RAW_BODY joined by newlines', function () {
|
||||
$headers = BnfexpressSignature::headers(
|
||||
method: 'post',
|
||||
path: '/admin/faqs',
|
||||
rawBody: '{"content":"hi"}',
|
||||
clientId: 'ev_admin',
|
||||
secret: 'shared-secret',
|
||||
timestamp: 1_700_000_000,
|
||||
);
|
||||
|
||||
$expected = hash_hmac('sha256', "POST\n/admin/faqs\n1700000000\n{\"content\":\"hi\"}", 'shared-secret');
|
||||
|
||||
expect($headers)->toBe([
|
||||
'X-Client-Id' => 'ev_admin',
|
||||
'X-Timestamp' => '1700000000',
|
||||
'X-Signature' => $expected,
|
||||
]);
|
||||
});
|
||||
|
||||
test('headers signs an empty raw body for a bodyless request', function () {
|
||||
$headers = BnfexpressSignature::headers(
|
||||
method: 'GET',
|
||||
path: '/admin/faqs/1',
|
||||
rawBody: '',
|
||||
clientId: 'ev_admin',
|
||||
secret: 'shared-secret',
|
||||
timestamp: 1_700_000_000,
|
||||
);
|
||||
|
||||
$expected = hash_hmac('sha256', "GET\n/admin/faqs/1\n1700000000\n", 'shared-secret');
|
||||
|
||||
expect($headers['X-Signature'])->toBe($expected);
|
||||
});
|
||||
|
||||
test('headers generates a fresh timestamp per call when none is given', function () {
|
||||
$before = time();
|
||||
|
||||
$headers = BnfexpressSignature::headers('GET', '/admin/faqs', '', 'ev_admin', 'secret');
|
||||
|
||||
expect((int) $headers['X-Timestamp'])->toBeGreaterThanOrEqual($before)
|
||||
->and((int) $headers['X-Timestamp'])->toBeLessThanOrEqual(time());
|
||||
});
|
||||
Reference in New Issue
Block a user