fluffydiscord / sylius-chatbot-bundle
Chatbot tool server endpoints, data sources and widget embed for Sylius 1.14 and 2.x shops
Package info
github.com/FluffyDiscord/sylius-chatbot-bundle
Type:symfony-bundle
pkg:composer/fluffydiscord/sylius-chatbot-bundle
Requires
- php: ^8.2
- ext-intl: *
- doctrine/collections: ^1.8 || ^2.1
- doctrine/doctrine-bundle: ^2.13 || ^3.2 || ^4.0
- doctrine/orm: ^2.20 || ^3.6 || ^4.0
- liip/imagine-bundle: ^2.13
- sylius/sylius: ^1.14 || ^2.2 || ^2.3@alpha
- symfony/config: ^6.4 || ^7.4 || ^8.0
- symfony/console: ^6.4 || ^7.4 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.4 || ^8.0
- symfony/event-dispatcher: ^6.4 || ^7.4 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/http-client: ^6.4 || ^7.4 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.4 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.4 || ^8.0
- symfony/intl: ^6.4 || ^7.4 || ^8.0
- symfony/routing: ^6.4 || ^7.4 || ^8.0
- symfony/security-core: ^6.4 || ^7.4 || ^8.0
- symfony/security-http: ^6.4 || ^7.4 || ^8.0
- symfony/serializer: ^6.4 || ^7.4 || ^8.0
- symfony/translation: ^6.4 || ^7.4 || ^8.0
- symfony/validator: ^6.4 || ^7.4 || ^8.0
- twig/twig: ^2.12 || ^3.3
Requires (Dev)
- monsieurbiz/sylius-cms-page-plugin: ^2.2
- phpunit/phpunit: ^11.0 || ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Exposes a small authenticated HTTP tool server (/chatbot/v1) for an external AI chatbot backend and embeds the chat widget into the Sylius shop layout.
Requires PHP ^8.2 and Sylius ^1.14 || ^2.2 || ^2.3.
Requirements
| Package | Constraint |
|---|---|
php |
^8.2 |
ext-intl |
* |
sylius/sylius |
^1.14 || ^2.2 || ^2.3@alpha |
doctrine/orm |
^2.20 || ^3.6 || ^4.0 |
doctrine/doctrine-bundle |
^2.13 || ^3.2 || ^4.0 |
symfony/* |
^6.4 || ^7.4 || ^8.0 |
twig/twig |
^2.12 || ^3.3 |
The @alpha on sylius/sylius is only because 2.3 has no stable tag yet (v2.3.0-ALPHA.1); it drops once 2.3 ships stable.
On Sylius 1.14 the shop runs on Symfony 6.4 and has no Twig Hooks, so the widget is registered as a sylius_ui template block on sylius.shop.layout.javascripts instead. That is the only mechanism 1.14 offers and Sylius deprecated it in the same release, so the container build reports sylius/ui-bundle deprecations for it — expected, not a bug. Everything else — endpoints, data sources, catalog notifications — is identical on both lines.
Installation
1. Composer
composer require fluffydiscord/sylius-chatbot-bundle
2. Register the bundle
config/bundles.php:
FluffyDiscord\SyliusChatbotBundle\FluffyDiscordSyliusChatbotBundle::class => ['all' => true],
3. Routes
config/routes/fluffydiscord_sylius_chatbot.yaml:
fluffydiscord_sylius_chatbot: resource: '@FluffyDiscordSyliusChatbotBundle/config/routes.php'
The endpoints live under /chatbot/v1 on the shop host and must not be behind the shop's _locale prefix.
4. Bundle configuration
config/packages/fluffydiscord_sylius_chatbot.yaml:
fluffy_discord_sylius_chatbot: api_secret: '%env(CHATBOT_API_SECRET)%' backend_url: '%env(CHATBOT_BACKEND_URL)%' ingest_secret: '%env(CHATBOT_INGEST_SECRET)%' widget: enabled: true site_key: '%env(CHATBOT_SITE_KEY)%' cdn_url: '%env(CHATBOT_WIDGET_CDN_URL)%'
backend_url is used by the backend-url attribute (the widget's API origin) and the catalog change notifier. widget.backend_url still works as a deprecated alias and is used when the root value is empty.
widget.cdn_url is the URL the <script src> loads chat.js from — set it to the Bunny CDN URL the backend publishes to via chatbot:widget:deploy (it must equal the backend's BUNNY_CDN_PURGE_URL). Only the script bytes move to the CDN; every API call still goes to backend_url. When empty it falls back to {backend_url}/widget/v1/chat.js (backend-served).
widget.enabled must be a literal boolean (it decides at compile time whether the widget hook is registered).
.env:
CHATBOT_API_SECRET=change-me CHATBOT_BACKEND_URL=https://chatbot.example.com CHATBOT_INGEST_SECRET=change-me CHATBOT_SITE_KEY=site-key
ingest_secret is the shop half of the catalog notification credential; the backend receives Authorization: Bearer <site_key>.<ingest_secret>. Outside the dev environment a non-https backend_url is refused and nothing is sent.
One site per channel
When each channel is its own site on the backend, map channel codes to site keys:
fluffy_discord_sylius_chatbot: api_secret: '%env(CHATBOT_API_SECRET)%' backend_url: '%env(CHATBOT_BACKEND_URL)%' ingest_secret: '%env(CHATBOT_INGEST_SECRET)%' widget: site_key: '%env(CHATBOT_SITE_KEY)%' channel_site_keys: CZ_WEB: '%env(CHATBOT_SITE_KEY_CZ)%' SK_WEB: '%env(CHATBOT_SITE_KEY_SK)%' DE_WEB: 'pk_live_de'
- A channel's site key is
channel_site_keys[<channel code>], elsesite_key. An entry is authoritative: a channel mapped to an empty value has no site key, it never falls back. site_keyis optional oncechannel_site_keyshas an entry; it then only serves unmapped channels.- Keys are channel codes (not normalized,
cz-webstayscz-web), values are strings — literal or%env()%. api_secretandingest_secretstay shared: every site must have the same shop tool-server secret and ingest secret configured.- With channel keys set, disabled channels get no catalog notifications and
notify-all --channel=<disabled>is refused. - Empty
channel_site_keys(the default) behaves like a single-site shop: channels, enabled or not, are never consulted for notifications.
5. Security
Add the chatbot_api firewall to config/packages/security.yaml before the Sylius shop firewall (firewalls match in order and shop matches ^/):
security: providers: chatbot_backend: memory: users: [] firewalls: chatbot_api: pattern: ^/chatbot/v1 stateless: true provider: chatbot_backend entry_point: FluffyDiscord\SyliusChatbotBundle\Security\ApiAuthenticationFailureHandler access_token: token_handler: FluffyDiscord\SyliusChatbotBundle\Security\ApiSecretAuthenticator failure_handler: FluffyDiscord\SyliusChatbotBundle\Security\ApiAuthenticationFailureHandler # shop: ... access_control: - { path: ^/chatbot/v1, roles: ROLE_CHATBOT_BACKEND }
The backend authenticates with Authorization: Bearer <CHATBOT_API_SECRET>.
What the bundle exposes
Three independent channels — do not conflate them:
- Tools — live, per-request calls the backend makes during a conversation (order status, stock/price right now).
- Sources — bulk catalog/content documents the backend pulls and ingests into its own retrieval index; never called per chat.
- Widget — the chat UI, embedded into the shop layout.
Tools and sources are HTTP endpoints under /chatbot/v1 on the shop host. Both use the error envelope
{ "error": { "code", "message", "violations" } }; violations is non-empty only for HTTP 422 (validation_failed).
Locales
Every locale the bundle accepts — the locale query parameter, the tool call context.locale, the Accept-Language
header and --locale — must be a locale 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 the channel (or the data source) serves,
first as the same locale and then as the same language, and everything the bundle then queries, renders and announces
uses the shop's own full locale code (cs_CZ), never the requested spelling and never a bare language.
What an unusable locale costs differs per endpoint, because each one 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 with a context.locale violation |
falls back to the shop's context locale |
GET /tools (Accept-Language) |
400 bad_request |
falls back to the shop's context locale |
notify-all --locale |
aborts, nothing announced | aborts, nothing announced |
Tools
The backend calls these while answering a shopper: the model decides it needs live data, the backend makes a server-to-server request to the shop mid-turn, and feeds the result back into the same conversation. Tools answer "what is true right now".
| Method | Path | Description |
|---|---|---|
| GET | /chatbot/v1/tools |
Tool definitions with generated JSON input schemas; labels translated per Accept-Language |
| POST | /chatbot/v1/tools/{name} |
Executes a tool with { arguments, context: { conversationId, locale, channelCode } } |
Shipped tools
get_order_status— order state, payment and shipping state, tracking codes, item count and total; requires the order number and the customer e-mail (uniform "not found" otherwise).get_product_availability— price, currency and stock for up to 20 variant codes; returns aproductsblock for the widget.
Adding 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 rely on constructor arguments — it is invoked at container compile time to index tools by name (duplicate names fail the container 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: it is Assert\Choice with the choices loaded from a service implementing ToolChoiceLoaderInterface (autoconfigured). The loaded values become the property's schema enum in the tool list, and Symfony's ChoiceValidator checks them when the tool is called.
use FluffyDiscord\SyliusChatbotBundle\Contract\ToolChoiceLoaderInterface; use FluffyDiscord\SyliusChatbotBundle\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 default for autoconfigured services).
- Choices are the exact values the tool accepts, in every locale — not translated labels. Duplicates are dropped.
- A loader that returns nothing leaves the argument out of the schema; any value sent anyway fails validation. A required argument (
Assert\NotBlank) with an empty loader makes the tool uncallable. - A loader that throws fails the tool list and the call. Return an empty list for missing data; throw only for misconfiguration.
nullpasses; addAssert\NotBlankto make the argument required.
Sources
Sources are not called per chat. The backend pulls them in bulk, chunks and embeds the documents, and stores the vectors in its own retrieval index; at chat time it searches that index instead of calling the shop. Sources answer "what the shop is" (catalog + content), the counterpart to tools' "what is true right now". Re-ingestion is driven by the catalog-change notifications below (delta) and by the backend's own full-sync schedule.
| Method | Path | Description |
|---|---|---|
| GET | /chatbot/v1/sources |
Data source definitions with served locales |
| GET | /chatbot/v1/sources/{name}?locale=cs_CZ&channel=&cursor=&ids[]= |
Keyset-paginated documents (200 per page); with ids[] (max 500) the cursor is ignored and nextCursor is null |
Shipped sources
products— indexable channel products per locale withProductMetadata(code, name, url, imageUrl, priceMinor, currency, inStock, taxons, attributes);taxonscarries taxon codes, the taxon names and the main taxon path live in the document text.categories— enabled taxons of the channel tree per locale (code, name, path, url, productCount). The tree is the channel's menu taxon subtree; within it a taxon is served only when every ancestor below the tree top is enabled too, so a disabled branch never leaks its children. The tree top itself — the menu taxon, or the tree root when there is none — never disqualifies anything beneath it, so disabling it empties the channel's menu without emptying this source; Sylius's own menu makes the same call (TaxonRepository::findChildrenByChannelMenuTaxon()filtersenabledon the rendered children only). The menu taxon is served as a category document, a bare tree root is not. A channel without a menu taxon serves the shop's only taxon tree, and is refused with409 ambiguous_taxon_treewhen the shop has several — there is then no way to tell this channel's categories from another channel's.productCountcounts the taxon's whole nested-set subtree and only products passingProductIndexabilityInterface, so it agrees with the category page (include_all_descendants: true) and with what the chatbot can return.cms_pages— enabled Monsieur Biz CMS pages per locale (registered only when the plugin is installed).
Every absolute URL a source or a tool emits — page URLs and imageUrl alike — is built on the resolved channel's hostname, not on the host the request arrived at (the channel's hostname is empty → the request host stands in). A read for channel=X therefore always hands back links on X's own domain. Only the host is swapped. Sylius stores no per-channel scheme or port, so both still come from the request context: set router.request_context.scheme (and host) for notify-all, which runs outside a request and would otherwise emit http://, and expect a shop served on a non-standard port to carry that port into every channel's URLs. One caveat on imageUrl: the swap reaches Liip's resolver because it shares the router's RequestContext, but a liip_imagine cache resolver in front of web_path memoises the finished absolute URL under a host-less key, so the first channel read would then feed its host to every other channel. Keep the chatbot's image filter on a host-agnostic resolver.
Adding a data source — implement ChatbotDataSourceInterface the same way a tool is added; SourceDefinition::$locales = null means "all locales of the current channel".
Extension points (both govern the products source):
| Interface | Default | Purpose |
|---|---|---|
ProductViewFactoryInterface |
ProductViewFactory |
price, stock, image and URL of an indexed product |
ProductIndexabilityInterface |
ProductIndexability |
which products the products source serves (default: enabled, in the channel, at least one priced enabled variant) |
Both are wired as container aliases; redefine the alias in the shop's services.yaml to replace them.
Catalog change notifications
These keep the backend's ingested sources fresh. Saving a Product, a ProductTranslation, a Taxon or a TaxonTranslation collects the changed external ids and one POST {backend_url}/api/v1/catalog/changes per (source, locale) is sent on kernel.terminate and on ConsoleEvents::TERMINATE (max 500 ids per request, 2 s timeout, 5 s max duration). The whole flush shares a single 5 s wall-clock budget: the requests are issued together and drained in one stream() loop, and whatever has not finished when the budget is spent is abandoned — a dropped notification costs at most one nightly cycle of staleness.
A ProductTranslation change announces its own locale only, a TaxonTranslation change announces categories for its own locale, a Product change announces every locale of sylius_locale, and a Taxon change announces categories for every locale without fanning out to its products. Every failure is logged as a warning and swallowed — a notification never breaks a shop request. With backend_url or ingest_secret empty, or with neither widget.site_key nor any widget.channel_site_keys entry set, nothing is sent and a warning names the missing key.
Which site receives a (source, locale):
widget.channel_site_keys |
Recipients |
|---|---|
| empty | the widget.site_key site, for every locale |
| set | the site of every enabled channel serving that locale; channels resolving to the same site key share one request |
With channel keys set, an enabled channel that serves a changed locale but resolves no key (unmapped, site_key empty) is skipped with a warning; a channel mapped to an empty value is skipped with a debug record. A locale no enabled channel serves is sent nowhere. The 500-id batching and the single 5 s flush budget cover every site's requests together.
bin/console fluffydiscord:chatbot:notify-all [--source=products|categories] [--locale=cs_CZ] [--channel=code] re-announces the whole catalog in 500-id batches, pausing 2 s between batches and honouring Retry-After on a 429. Pass --channel when no channel can be resolved from the CLI context; without --locale the locales are taken from the resolved channel, so each channel's catalog is paired with the locales that channel actually serves. Without channel keys the catalog goes to the widget.site_key site. With channel keys it goes to the resolved channel's site only, and a disabled channel or one without a site key aborts the run; run it once per channel. A --locale the source does not serve aborts the run instead of announcing a locale the backend cannot use.
Widget
When widget.enabled is true the bundle injects, via the sylius_shop.base#javascripts twig hook on Sylius 2 and the sylius.shop.layout.javascripts template block on Sylius 1.14:
<script src="{widget_cdn_url}" defer></script> <ai-chat-widget site-key="{site_key}" locale="{app.locale}" backend-url="{backend_url}"></ai-chat-widget>
site_key is resolved at render time by the fluffydiscord_chatbot_site_key(fallback) Twig function: the current channel's widget.channel_site_keys entry, else the template's own site_key. The shop's ChannelContextInterface decides the channel; without a resolvable channel the fallback is used. When the resolved key is empty nothing is rendered — neither the script nor the element.
The template can also be included directly with a plain context — the channel key is still applied:
{% include '@FluffyDiscordSyliusChatbot/shop/widget.html.twig' with {
backend_url: 'https://chatbot.example.com',
site_key: 'site-key',
widget_cdn_url: '',
} only %}
widget_cdn_url defaults to {backend_url}/widget/v1/chat.js when widget.cdn_url is unset. The widget talks only to the backend at backend_url; it never calls /chatbot/v1 itself. Tool calls and source ingestion are the backend's job — the widget only renders what the backend streams back.