Search by

fluffydiscord / symfony-honkers-bundle

FluffyDiscord

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

Statistics

Installs: 276

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.1 2026-10-27 08:16 UTC

This package is auto-updated.

Last update: 2026-10-27 08:17:04 UTC


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

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
  1. config/bundles.php — Symfony Flex already did this if it's on.
FluffyDiscord\HonkersBundle\FluffyDiscordHonkersBundle::class => ['all' => true],
  1. config/packages/fluffy_discord_honkers.yaml
fluffy_discord_honkers:
    backend_url: '%env(CHATBOT_BACKEND_URL)%'
  1. .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=
  1. 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');
};
  1. config/packages/security.yaml — the backend sends Authorization: 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/pair sits outside ^/chatbot/v1 on 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_equals for 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() throws PairingException → error page with its code, nothing else happens. Any other exception → 500. Use a PairingErrorCode value for a translated message: throw new PairingException('Read-only store.', PairingErrorCode::Unsupported->value).
  • No writer aliased → /chatbot/pair answers 409: "paste the credentials manually".

Connect from the dashboard

Needs a credentials writer. Then nobody types a secret:

  1. The admin clicks Connect on a tool-server connection in the honkers.dev dashboard.
  2. The browser opens {connection base URL}/chatbot/pair?code=… on your shop.
  3. The bundle makes two 64-character secrets, redeems the code at your configured backend_url and calls save().
  4. 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_shop or a failing save() 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 channel's default locale
GET /tools (Accept-Language) 400 bad_request falls back to the channel's default locale

Which locales are served, and what "default 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).

Translations

The bundle's strings ship in cs, en, de, sk, pl, hu, ro, sl, hr and ru, in the honkers domain. Override a string or add a language in your app:

translations/honkers.it.yaml

fluffydiscord_honkers:
    tool:
        failure: 'Al momento non è possibile recuperare le informazioni. Riprova più tardi.'

A string missing in the visitor's language comes from the channel's default locale (ChatbotLocaleContextInterface::getDefaultLocale()), not your app's global fallback. Your own tools' descriptions and labels work the same way, in messages unless you set translationDomain on the definition.

API error messages and the pairing page use the request locale.

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 in messages, or in the translationDomain you pass to ToolDefinition — see Translations.

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\Choice option except choices, callback and strict: multiple, min, max, match and the messages. Violations carry Choice's codes and parameters.
  • The property must be string, or array with multiple: true (published as items.enum, plus minItems/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.
  • null passes; add Assert\NotBlank to 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, or backend_url empty → 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 the dev env.

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.