fluffydiscord / symfony-honkers-bundle
Vanilla Symfony bundle exposing the Honkers chatbot tool-server endpoints, security and wiring
Package info
github.com/FluffyDiscord/symfony-honkers-bundle
Type:symfony-bundle
pkg:composer/fluffydiscord/symfony-honkers-bundle
Requires
- php: ^8.1
- fluffydiscord/honkers-sdk: ^2.0
- nyholm/psr7: ^1.8
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/event-dispatcher: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/http-client: ^6.4 || ^7.0 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/routing: ^6.4 || ^7.0 || ^8.0
- symfony/security-core: ^6.4 || ^7.0 || ^8.0
- symfony/security-http: ^6.4 || ^7.0 || ^8.0
- symfony/serializer: ^6.4 || ^7.0 || ^8.0
- symfony/translation: ^6.4 || ^7.0 || ^8.0
- symfony/validator: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- phpunit/phpunit: ^10.0 || ^11.0 || ^12.0 || ^13.0
- psr/log: ^1.0 || ^2.0 || ^3.0
Suggests
- psr/log: Log failed tool executions — inject any PSR-3 logger (monolog/monolog)
Provides
None
Conflicts
None
Replaces
None
README
Symfony bundle for the honkers.dev chatbot: the /chatbot/v1 tool-server endpoints,
their auth, one-click pairing and outbound reporting. PHP 8.1+, Symfony 6.4+. MIT.
Sylius users: use fluffydiscord/sylius-honkers-plugin.
Upgrading from 1.x: see UPGRADE-2.0.md.
Features
- Tools and data sources — one autoconfigured class each, no config
- Connect from the dashboard — the shop admin clicks Connect; nobody copies a secret
- Store credentials elsewhere — database, vault, per-channel keys
- Push catalog changes — the chatbot re-indexes just the entries you changed
- Chat click tracking — chat link visits reported with no code
- Widget — chat embed markup from your config
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /chatbot/v1/tools |
List tools with generated JSON input schemas; labels translated per Accept-Language. |
| POST | /chatbot/v1/tools/{name} |
Call a tool with { arguments, context: { conversationId, locale, channelCode } }. |
| GET | /chatbot/v1/sources |
List data sources with served locales. |
| GET | /chatbot/v1/sources/{name} |
Read a data source, keyset-paginated. |
| GET | /chatbot/pair?code=… |
Browser lands here when the admin clicks Connect. |
Tools answer "what is true right now" — the backend calls them mid-conversation for live data. Sources answer "what the shop is" — the backend pulls them in bulk and ingests them into its own retrieval index; never called per chat.
Install
composer require fluffydiscord/symfony-honkers-bundle
config/bundles.php— Symfony Flex already did this if it's on.
FluffyDiscord\HonkersBundle\FluffyDiscordHonkersBundle::class => ['all' => true],
config/packages/fluffy_discord_honkers.yaml
fluffy_discord_honkers: backend_url: '%env(CHATBOT_BACKEND_URL)%'
.env.local— skip the three secrets if you connect from the dashboard with your own credentials store.
CHATBOT_BACKEND_URL=https://honkers.dev CHATBOT_API_SECRET= CHATBOT_INGEST_SECRET= CHATBOT_SITE_KEY=
config/routes/fluffy_discord_honkers.php— routes aren't auto-loaded.
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; return static function (RoutingConfigurator $routes): void { $routes->import('@FluffyDiscordHonkersBundle/config/routes.php'); };
config/packages/security.yaml— the backend sendsAuthorization: Bearer <api secret>.
security: providers: chatbot_backend: memory: users: [] firewalls: chatbot_api: pattern: ^/chatbot/v1 stateless: true provider: chatbot_backend entry_point: FluffyDiscord\HonkersBundle\Security\ApiAuthenticationFailureHandler access_token: token_handler: FluffyDiscord\HonkersBundle\Security\ApiSecretAuthenticator failure_handler: FluffyDiscord\HonkersBundle\Security\ApiAuthenticationFailureHandler access_control: - { path: ^/chatbot/v1, roles: ROLE_CHATBOT_BACKEND }
Firewalls match in order — put chatbot_api before any catch-all firewall that also matches ^/.
/chatbot/pairsits outside^/chatbot/v1on purpose: it's a browser redirect, the one-time code is the proof. Keep it reachable without login.
Configuration
fluffy_discord_honkers: backend_url: '' widget: enabled: true cdn_url: '' defer: true
| Option | Default | Meaning |
|---|---|---|
backend_url |
'' |
honkers.dev origin for outbound calls and pairing. Empty → nothing sent. Not https outside dev → refused. |
widget.enabled |
true |
Parameter fluffydiscord_honkers.widget.enabled for whoever prints the widget. |
widget.cdn_url |
'' |
Widget script URL. '' = https://honkers.b-cdn.net/widget/v1/chat.js. |
widget.defer |
true |
false → plain <script src="…">, no defer. |
Credentials aren't config. The default provider reads them from env:
| Env var | Used for | Empty |
|---|---|---|
CHATBOT_API_SECRET |
Bearer the backend sends to /chatbot/v1 |
every call → 401 unauthorized |
CHATBOT_INGEST_SECRET |
outbound calls, Bearer <site key>.<ingest secret> |
hasIngestSecret() → false, no outbound calls; widget still works |
CHATBOT_SITE_KEY |
outbound calls, widget | findCurrentSite() → null |
Missing env vars never break the boot.
Store credentials elsewhere
Keep the secrets in a database or vault → implement CredentialsProviderInterface and alias it.
src/Chatbot/DatabaseCredentials.php
use FluffyDiscord\Honkers\DTO\SiteCredentials; use FluffyDiscord\HonkersBundle\Contract\CredentialsProviderInterface; class DatabaseCredentials implements CredentialsProviderInterface { public function __construct( private readonly ChatbotSettingsRepository $settingsRepository, ) { } public function isApiSecretValid(#[\SensitiveParameter] string $token): bool { $settings = $this->settingsRepository->findCurrent(); if ($settings === null) { return false; } return hash_equals($settings->apiSecretHash, hash('sha256', $token)); } public function findCurrentSite(): ?SiteCredentials { $settings = $this->settingsRepository->findCurrent(); if ($settings === null) { return null; } return new SiteCredentials($settings->siteKey, $settings->ingestSecret); } }
config/services.yaml
services:
+ FluffyDiscord\HonkersBundle\Contract\CredentialsProviderInterface: '@App\Chatbot\DatabaseCredentials'
| Method | Called | Return |
|---|---|---|
isApiSecretValid($token) |
every /chatbot/v1 request |
true = the backend may call. You own the compare → store only a hash if you like. |
findCurrentSite() |
landing requests with ?gooseclid=, your own outbound/widget code |
the site of the current request; null → nothing reported, empty ingest secret → widget only |
- Read the store on each call, don't keep it in a property → a re-paired secret works without a restart, also in long-running workers.
- Use
hash_equalsfor the compare.
Enable Connect
Implement CredentialsWriterInterface too → the dashboard's Connect button fills your store.
use FluffyDiscord\Honkers\Pairing\HostMatcher; use FluffyDiscord\HonkersBundle\Contract\CredentialsWriterInterface; use FluffyDiscord\HonkersBundle\Pairing\PairedCredentials; use Symfony\Component\HttpFoundation\Request; class DatabaseCredentials implements CredentialsProviderInterface, CredentialsWriterInterface { public function __construct( private readonly ChatbotSettingsRepository $settingsRepository, private readonly HostMatcher $hostMatcher, ) { } public function findTrustedHost(Request $request): ?string { foreach ($this->settingsRepository->findShopHostnames() as $shopHostname) { $isRequestHost = $this->hostMatcher->isSameHost($shopHostname, $request->getHost()); if ($isRequestHost) { return $shopHostname; } } return null; } public function save(PairedCredentials $credentials, Request $request): void { $this->settingsRepository->saveCredentials( $credentials->siteKey, hash('sha256', $credentials->apiSecret), $credentials->ingestSecret, ); } }
config/services.yaml
services:
FluffyDiscord\HonkersBundle\Contract\CredentialsProviderInterface: '@App\Chatbot\DatabaseCredentials'
+ FluffyDiscord\HonkersBundle\Contract\CredentialsWriterInterface: '@App\Chatbot\DatabaseCredentials'
| Method | Return / effect |
|---|---|
findTrustedHost($request) |
the hostname this shop answers on, from your own data; null → pairing_hostname_missing |
save($credentials, $request) |
store siteKey, apiSecret, ingestSecret; channelCodes = the connection's channels, if you key per channel; verifiedDomains = the website's verified domains, to check each channel's host |
save()throwsPairingException→ error page with its code, nothing else happens. Any other exception → 500. Use aPairingErrorCodevalue for a translated message:throw new PairingException('Read-only store.', PairingErrorCode::Unsupported->value).- No writer aliased →
/chatbot/pairanswers 409: "paste the credentials manually".
Connect from the dashboard
Needs a credentials writer. Then nobody types a secret:
- The admin clicks Connect on a tool-server connection in the honkers.dev dashboard.
- The browser opens
{connection base URL}/chatbot/pair?code=…on your shop. - The bundle makes two 64-character secrets, redeems the code at your configured
backend_urland callssave(). - The browser goes back to the dashboard, which tests the connection with the new API secret.
The dashboard starts pairing only for a connection whose base URL is on a verified domain of the website.
The host of the connection's base URL must equal findTrustedHost(). Case, a trailing dot and IDN
spelling don't matter, www. does: www.shop.example ≠ shop.example. Mismatch → pairing_wrong_shop,
nothing saved. Return a hostname you stored, never the request's Host header — anyone can send any Host.
Compare your own hosts the same way with HostMatcher (autowired).
The backend never redirects the secrets. Its answer redirects → not followed, invalid_response. Its
return URL off the backend_url origin → invalid_response, nothing saved.
Something fails → a short page in the request locale (English, Czech shipped) with the message and the error code:
| Code | Status | Cause |
|---|---|---|
| — | 409 | No CredentialsWriterInterface service |
invalid_code |
400 | code missing or malformed |
backend_url_refused |
400 | backend_url empty, or not https outside dev |
pairing_hostname_missing |
400 | findTrustedHost() returned null |
pairing_wrong_shop |
400 | Base URL host ≠ trusted host |
pairing_not_found |
400 | Code unknown, used, or older than 10 minutes |
rate_limited |
400 | Too many unknown codes from the shop's IP; wait 10 minutes |
transport_failed |
400 | Backend unreachable |
validation_failed, invalid_request, invalid_response |
400 | Backend refused the request, answered unexpectedly or redirected |
code of the PairingException save() throws |
400 | Your writer refused |
After
pairing_wrong_shopor a failingsave()the code is already used up → click Connect again.
Error format
Every /chatbot/v1 endpoint returns errors as:
{ "error": { "code": "...", "message": "...", "violations": [] } }
violations is non-empty only on HTTP 422 (validation_failed).
Locales
Every locale you send must be one ICU knows (symfony/intl). Spelling does not matter: cs-CZ,
cs_cz and CS-cz all mean cs_CZ. The requested locale is matched against the locales served —
first as the same locale, then as the same language — and everything queried and rendered uses the
served locale's own full code, never the requested spelling and never a bare language.
What a bad locale costs differs per endpoint, because each has somewhere different to fall back to:
| Endpoint | Not an ICU locale | Valid but unserved |
|---|---|---|
GET /sources/{name}?locale= |
400 invalid_locale |
400 invalid_locale |
POST /tools/{name} (context.locale) |
422 validation_failed, a context.locale violation |
falls back to the current locale |
GET /tools (Accept-Language) |
400 bad_request |
falls back to the current locale |
Which locales are served, and what "current locale" means, comes from ChatbotLocaleContextInterface.
The default returns the kernel locale only; override that service to serve more (the Sylius plugin
makes it channel-aware).
Add a tool
One class plus one arguments DTO, no configuration:
readonly class MyArguments { public function __construct( #[Assert\NotBlank] #[Assert\Length(max: 64)] public string $query = '', ) { } } readonly class MyTool implements ChatbotToolInterface { public function getDefinition(): ToolDefinition { return new ToolDefinition('my_tool', 'app.chatbot.my_tool.description'); } public function getArgumentsClass(): string { return MyArguments::class; } public function execute(object $arguments, ToolCallContext $context): ToolResult { return new ToolResult([new ContentItem('...')]); } }
The interface is autoconfigured. The input schema is generated from the DTO: Assert\NotBlank →
required, Assert\Email → e-mail format, Assert\Choice → enum, Assert\Length → maxLength,
nullable → optional. Descriptions and labels are translation keys resolved with the request locale.
getDefinition() must not read constructor arguments — it runs at container compile time to index
tools by name, and duplicate names fail the build.
Choices loaded at runtime
Assert\Choice takes a fixed list or a static callback — no services. When the allowed values live
in the database, use #[ToolChoice] instead: Assert\Choice with the choices loaded from a
ToolChoiceLoaderInterface service (autoconfigured). The loaded values become the property's schema
enum, and Symfony's ChoiceValidator checks them on every call.
use FluffyDiscord\Honkers\Contract\ToolChoiceLoaderInterface; use FluffyDiscord\Honkers\Validator\ToolChoice; readonly class RegionChoiceLoader implements ToolChoiceLoaderInterface { public function __construct( private RegionRepository $regionRepository, ) { } public function loadChoices(): array { return $this->regionRepository->findAllNames(); } } readonly class FindByRegionArguments { public function __construct( #[ToolChoice(loader: RegionChoiceLoader::class, message: 'The region argument must be one of the listed names.')] public ?string $region = null, ) { } }
- Takes every
Assert\Choiceoption exceptchoices,callbackandstrict:multiple,min,max,matchand the messages. Violations carryChoice's codes and parameters. - The property must be
string, orarraywithmultiple: true(published asitems.enum, plusminItems/maxItems). Anything else fails the tool list. - The loader is looked up by its service id, which must be its class name (the autoconfigured default).
- Choices are the exact values the tool accepts, in every locale — not translated labels. Duplicates are dropped.
- An empty loader leaves the argument out of the schema; any value sent then fails validation. A
required argument (
Assert\NotBlank) with an empty loader makes the tool uncallable. - Return an empty list for missing data; throw only for misconfiguration — a loader that throws fails both the tool list and the call.
nullpasses; addAssert\NotBlankto make the argument required.
Add a data source
Implement ChatbotDataSourceInterface the same way a tool is added — one autoconfigured class.
SourceDefinition::$locales = null means "all locales of the current context".
GET /sources/{name}?locale=cs_CZ&cursor=&ids[]= returns keyset-paginated documents, 200 per page.
With ids[] (max 500) the cursor is ignored and nextCursor is null.
Push catalog changes
Tell honkers.dev which entries changed so it re-indexes them. Inject
FluffyDiscord\Honkers\Ingest\CatalogIngestClient — it is wired to Symfony's HTTP client (2 s timeout,
5 s max duration) and your backend_url. Pass the site from CredentialsProviderInterface per call:
The backend accepts changes only from an active site. A draft site answers 401 — activate it in the dashboard first.
use FluffyDiscord\Honkers\DTO\CatalogChange; use FluffyDiscord\Honkers\Enum\CatalogSourceName; $site = $credentialsProvider->findCurrentSite(); $canSend = $site !== null && $site->hasIngestSecret(); if ($canSend) { $result = $catalogIngestClient->send($site, new CatalogChange( CatalogSourceName::Products, 'cs_CZ', ['CLIPPER-01', 'CLIPPER-02'], )); }
Max 500 ids per call. The request sends Authorization: Bearer <site key>.<ingest secret>. $result->accepted is the 202;
$result->isThrottled() is a 429 or 503: nothing was queued, send the same ids again after $result->retryAfterSeconds.
Widget
The bundle doesn't print the widget; it gives you the pieces. WidgetSnippet renders the markup,
the widget.* options are parameters:
use FluffyDiscord\Honkers\Widget\WidgetSnippet; use FluffyDiscord\HonkersBundle\Contract\CredentialsProviderInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; class ChatWidget { public function __construct( private readonly WidgetSnippet $widgetSnippet, private readonly CredentialsProviderInterface $credentialsProvider, #[Autowire(param: 'fluffydiscord_honkers.backend_url')] private readonly string $backendUrl, #[Autowire(param: 'fluffydiscord_honkers.widget.cdn_url')] private readonly string $cdnUrl, #[Autowire(param: 'fluffydiscord_honkers.widget.defer')] private readonly bool $defer, ) { } public function render(): string { $site = $this->credentialsProvider->findCurrentSite(); if ($site === null) { return ''; } return $this->widgetSnippet->render($this->backendUrl, $site->siteKey, $this->cdnUrl, '', $this->defer); } }
Output:
<script src="https://honkers.b-cdn.net/widget/v1/chat.js" defer></script> <ai-chat-widget site-key="{site key}" backend-url="{backend_url}"></ai-chat-widget>
The widget talks only to backend_url; it never calls /chatbot/v1 itself.
(fluffydiscord/sylius-honkers-plugin injects it into the shop layout for you.)
Chat click tracking
Visits from chat links are reported for you. Links the chat sends carry ?gooseclid=…; when such a
page loads with a 2xx, the bundle tells honkers.dev after the response is sent — even if the widget is
blocked. Needs backend_url and a site from findCurrentSite().
- Reported: main GET request, no prefetch, page address without query or fragment.
findCurrentSite()→null, empty ingest secret, orbackend_urlempty → nothing sent, nothing logged.findCurrentSite()throws → logged warning, nothing sent.- Backend unreachable → logged warning; your page is never affected.
- Non-https
backend_url→ skipped with a logged warning outside thedevenv.
Report orders yourself. Remember the gooseclid (e.g. in the session) and, when the order is
placed, call FluffyDiscord\Honkers\Telemetry\TelemetryClient:
use FluffyDiscord\Honkers\DTO\ChatOrder; $site = $credentialsProvider->findCurrentSite(); $canSend = $site !== null && $site->hasIngestSecret(); if ($canSend) { $telemetryClient->reportOrder($site, new ChatOrder( $clickId, 'ORDER-1042', 129900, 'CZK', )); }
Revenue is in ISO 4217 minor units: 129900 = 1299.00 CZK.
It throws TelemetryException on failure — catch and log it.
Tests
composer install vendor/bin/phpunit
License
MIT — see LICENSE.