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:
Nyan Lin Paing
2026-08-30 23:52:21 +07:00
parent 95b369174d
commit b8d31e3dc4
30 changed files with 1362 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
---
paths:
- 'app-modules/shared/src/Bnfexpress/**'
---
# Bnfexpress
## bnfexpress admin API calls go through BnfexpressAdminClient
Signed backend-to-backend calls to bnfexpress's admin API (EV FAQs, agent instructions, chat history) go through `Modules\Shared\Bnfexpress\BnfexpressAdminClient` — do not call `Http::` directly against BNFEXPRESS_AI_API_URL elsewhere.
Auth is HMAC, not JWT/session: X-Client-Id/X-Timestamp/X-Signature per `BnfexpressSignature::headers()`, signed over `METHOD\nPATH\nTIMESTAMP\nRAW_BODY` (path only, no query string; empty string body for GET/DELETE). Timestamps must be generated fresh per request (server rejects >300s skew) — never cache/reuse a signed header set.
Config lives in `config('services.bnfexpress')` (BNFEXPRESS_AI_API_URL/CLIENT_ID/CLIENT_SECRET in .env). The client_secret must match bnfexpress's own ADMIN_SERVICE_CLIENTS entry for ev_admin — get it from whoever manages that deploy.
Non-2xx responses throw `BnfexpressApiException` carrying the gateway's `{"detail": "..."}` message. Verify signing end-to-end with `php artisan bnfexpress:smoke-test` before wiring up any UI.
+8
View File
@@ -0,0 +1,8 @@
# Project Rules Index
Before planning or editing, find the row whose globs match the file's path and read that rule file.
| Applies to | Rule file |
| --- | --- |
| app-modules/shared/src/Bnfexpress/** | .ai/rules/bnfexpress.md |
| app-modules/*/src/Filament/Pages/** | .ai/rules/pages.md |
+13
View File
@@ -0,0 +1,13 @@
---
paths:
- 'app-modules/*/src/Filament/Pages/**'
---
# Pages
## Non-Resource Filament pages need an explicit table-rendering view + deferLoading-aware tests
A `Filament\Pages\Page implements HasTable` (not a Resource) does NOT render its table automatically — it must set `protected string $view = '<module>::filament.pages.<slug>';` pointing at a Blade file containing `<x-filament-panels::page>{{ $this->table }}</x-filament-panels::page>` (see `ManageFaqs`/`ViewEvChatHistory`/`BookingsRevenueReport`). Omitting this silently renders an empty page — no error, just a blank `fi-page-content`.
Filament v4 tables default to deferred loading. In Pest/Livewire tests, call `->loadTable()` before any `assertSee()`/`assertCanSeeTableRecords()` on a freshly-mounted component, or the table body won't be in the rendered HTML yet.
For custom-data (`->records()`-backed, non-Eloquent) tables: use `->callTableAction($name, $record, data: [...])` / `->mountTableAction(...)` (not the generic `->callAction()`, which targets page-level actions and misses table header/record actions), and use `->assertMountedActionModalSee(...)` to check `->modalContent()` output — modal content is lazily rendered and won't appear in a plain `->html()`/`->assertSee()` snapshot even after mounting the action. See `[[project_internachi_modular]]`-style module layout in `app-modules/ai-agent`.
+5
View File
@@ -99,3 +99,8 @@ VITE_APP_NAME="${APP_NAME}"
FASTAPI_AGENT_JWT_SECRET= FASTAPI_AGENT_JWT_SECRET=
FASTAPI_AGENT_JWT_ALGORITHM=HS256 FASTAPI_AGENT_JWT_ALGORITHM=HS256
# Must match the ev_admin entry in bnfexpress's own ADMIN_SERVICE_CLIENTS.
BNFEXPRESS_AI_API_URL=http://bnfexpress-app:8000
BNFEXPRESS_AI_CLIENT_ID=ev_admin
BNFEXPRESS_AI_CLIENT_SECRET=
+24
View File
@@ -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"
]
}
}
}
@@ -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>
@@ -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', 'view_customers',
'manage_settings', 'manage_settings',
'view_reports', 'view_reports',
'manage_ai_agent',
]; ];
/** /**
@@ -46,6 +47,7 @@ class RolePermissionSeeder extends Seeder
'view_customers', 'view_customers',
'manage_settings', 'manage_settings',
'view_reports', 'view_reports',
'manage_ai_agent',
], ],
'admin' => [ 'admin' => [
'manage_catalog', 'manage_catalog',
@@ -59,6 +61,7 @@ class RolePermissionSeeder extends Seeder
'view_customers', 'view_customers',
'manage_settings', 'manage_settings',
'view_reports', 'view_reports',
'manage_ai_agent',
], ],
'support' => [ 'support' => [
'view_bookings', '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());
});
@@ -21,6 +21,7 @@ use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession; use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
use Modules\AiAgent\AiAgentPlugin;
use Modules\Booking\BookingPlugin; use Modules\Booking\BookingPlugin;
use Modules\Catalog\CatalogPlugin; use Modules\Catalog\CatalogPlugin;
use Modules\Cms\CmsPlugin; use Modules\Cms\CmsPlugin;
@@ -51,6 +52,7 @@ class AdminPanelProvider extends PanelProvider
NavigationGroup::make()->label('Operations'), NavigationGroup::make()->label('Operations'),
NavigationGroup::make()->label('Reports'), NavigationGroup::make()->label('Reports'),
NavigationGroup::make()->label('CMS'), NavigationGroup::make()->label('CMS'),
NavigationGroup::make()->label('AI Agent'),
]) ])
->plugins([ ->plugins([
CatalogPlugin::make(), CatalogPlugin::make(),
@@ -60,6 +62,7 @@ class AdminPanelProvider extends PanelProvider
IdentityPlugin::make(), IdentityPlugin::make(),
ReportingPlugin::make(), ReportingPlugin::make(),
CmsPlugin::make(), CmsPlugin::make(),
AiAgentPlugin::make(),
// T6.5 — ops convenience for browsing storage/logs/*.log // T6.5 — ops convenience for browsing storage/logs/*.log
// in-browser; distinct from the structured, per-model audit // in-browser; distinct from the structured, per-model audit
// trail (AuditLogResource, T6.2). No extra permission gate: // trail (AuditLogResource, T6.2). No extra permission gate:
+1
View File
@@ -14,6 +14,7 @@
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"internachi/modular": "^3.0", "internachi/modular": "^3.0",
"modules/ai-agent": "*",
"modules/booking": "*", "modules/booking": "*",
"modules/catalog": "*", "modules/catalog": "*",
"modules/cms": "*", "modules/cms": "*",
Generated
+33 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "00ca2f59b7cce7ed3ade4856855a5d51", "content-hash": "947c21682eb62d7b6f63b0f1e11dbe38",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -4663,6 +4663,38 @@
}, },
"time": "2022-12-02T22:17:43+00:00" "time": "2022-12-02T22:17:43+00:00"
}, },
{
"name": "modules/ai-agent",
"version": "1.0",
"dist": {
"type": "path",
"url": "app-modules/ai-agent",
"reference": "3a8b4cb091495eaa57bbb80f6879f85a014318bd"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Modules\\AiAgent\\Providers\\AiAgentServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Modules\\AiAgent\\": "src/",
"Modules\\AiAgent\\Tests\\": "tests/",
"Modules\\AiAgent\\Database\\Factories\\": "database/factories/",
"Modules\\AiAgent\\Database\\Seeders\\": "database/seeders/"
}
},
"license": [
"proprietary"
],
"transport-options": {
"symlink": true,
"relative": true
}
},
{ {
"name": "modules/booking", "name": "modules/booking",
"version": "1.0", "version": "1.0",
+6
View File
@@ -40,6 +40,12 @@ return [
'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'), 'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'),
], ],
'bnfexpress' => [
'ai_api_url' => env('BNFEXPRESS_AI_API_URL', 'http://bnfexpress-app:8000'),
'client_id' => env('BNFEXPRESS_AI_CLIENT_ID', 'ev_admin'),
'client_secret' => env('BNFEXPRESS_AI_CLIENT_SECRET'),
],
'sms' => [ 'sms' => [
'enabled' => env('SMS_ENABLED', false), 'enabled' => env('SMS_ENABLED', false),
'sms_poh' => [ 'sms_poh' => [