WordPress & WooCommerce plugin, BYOK, free file parsing, hardened security
## New: official WordPress & WooCommerce plugin A first-party WordPress + WooCommerce plugin now ships with every Blengi deployment. Drop it on any WordPress 6.4+ install running PHP 7.4 or newer, paste a workspace API token, and the streaming chat widget loads on every public page — with content sync, product cards, coupon application, order lookup, and lead mirroring out of the box. **Widget embed.** One asynchronous `<script>` tag injected into `wp_footer`, scoped to the post types you opt into. Hidden on `wp-admin`, login, AJAX, REST, XML-RPC, and cron paths. Emits `data-page-dir` and `data-page-locale` attributes so the bar mirrors correctly on Arabic, Hebrew, Persian, and Urdu locales. **Content sync.** Bulk + delta ingest of posts, pages, custom post types, and WooCommerce products as Blengi knowledge sources. SHA- 256 content hash short-circuits re-indexing on unchanged content, so re-running "Sync now" against a stable site is effectively free. **Page-builder rendering.** Elementor, Beaver Builder, Oxygen, and Bricks pages are rendered with the builder's own native API before sync, so the synced HTML reflects what visitors actually see. Divi posts route through `the_content` with `setup_postdata` primed so third-party filter callbacks see a valid `$post` global. A `pitchbar_post_content_html` filter lets sites post-process the final HTML (strip nav chrome, force a custom template, redact a section) without forking the plugin. **Resumable sync.** Each sync pass enforces a 20-second wall-clock budget. When a large site can't finish in one tick, the plugin persists a resume marker as a WordPress transient and schedules a WP-Cron continuation 30 seconds out. Shared hosting with a 30s `max_execution_time` cap no longer fatals mid-sync. The Plugins admin shows a soft notice while a chunked sync is in flight. **WooCommerce shopper context.** When a logged-in WC customer visits a page, the plugin attaches a signed `data-shopper-token` to the widget script tag. The widget forwards it to `/widget/init`; Blengi verifies it and bakes `wp_user_id` + `email_hash` (never plaintext) claims into the chat session. **Order lookup tool.** EcommercePreset's `order_status` capability maps to a real tool. When the visitor asks about orders, the LLM calls `lookup_order(limit, order_number?)`, which POSTs to `/wp-json/pitchbar/v1/orders/lookup` over HMAC-signed callback. Returns id, number, status, total, currency, items, tracking URL (AfterShip / ST tracking / generic meta), and the customer's order view URL. **Coupon emission & apply.** The plugin's `CouponSyncer` snapshots publish-status `shop_coupon` entries after every product sync (max 50), filters expired / over-limit, and posts them to Blengi. The LLM emits `<coupon code="WELCOME10" .../>` markers in chat replies; the widget renders them as cards with Copy and Apply buttons. Apply stages the code in a 15-minute transient that auto-applies on the visitor's next cart load via the `woocommerce_load_cart_from_session` hook. **Abandoned cart re-engagement.** A new `BehaviorRule.kind = "abandoned_cart"` lets you re-engage a visitor whose cart has been idle. The plugin's `cart-state.js` mirrors every WooCommerce `added_to_cart` / `removed_from_cart` event into `localStorage`; the widget polls every 30 seconds and fires when the cart sits past the rule's `idle_minutes` threshold. **Lead push-back.** Every Blengi lead is mirrored to WordPress as a WC customer (when Woo is active) or WP subscriber, with `pitchbar_lead_id` + `pitchbar_conversation_id` in user meta so the store owner can correlate. Idempotent on email — re-pushes update, never duplicate. **Workspace API tokens.** New `/settings/api-tokens` admin page issues workspace-scoped bearer tokens with granular abilities (the plugin uses `wp:integration`). Blengi stores only the SHA-256 hash of the plaintext; revoke instantly invalidates without deleting audit history. Cross-workspace revoke is forbidden. **HMAC signed callbacks.** Blengi → plugin calls (order lookup, coupon apply, lead push) are signed with HMAC-SHA256 over the raw body using a per-token `shopper_signing_secret`. 5-minute replay window. The plugin verifies via constant-time `hash_equals`. **Super-admin plugin distribution.** New `/admin/integrations/wordpress` page lets super-admins build and download the install-ready zip directly from the Blengi admin. Backed by the new `php artisan pitchbar:build-wp-plugin` Artisan command. ## Improvements **Plugin-load-order race fixed.** v1.x of the plugin trusted `class_exists('WooCommerce')` at `plugins_loaded` priority 10, which silently disabled WooCommerce features when alphabetical plugin order put `pitchbar` before `woocommerce`. v2.0.0 defers every WC-dependent registration to the `woocommerce_loaded` action (with a `did_action` fallback for the priority-20+ case), so the race is eliminated. **Coupon enumeration via `shop_coupon` CPT.** Replaces the v1.x reliance on `wc_get_coupons()` (not public WC API on every release) with `get_posts(['post_type' => 'shop_coupon'])` + `new WC_Coupon($id)`. Works on every WC version since 2.0. **`abandoned_cart` admin trigger.** Backend already supported it; the agent Behavior triggers admin page now exposes it in the kind dropdown. **Comprehensive WordPress docs.** Seven new documentation pages under the "WordPress & WooCommerce" group: Overview, Install & connect, Content sync, Page builders, WooCommerce deep links, REST API reference, Troubleshooting. ## Fixes **Page-builder content reaches the RAG store.** Pre-v2.0 the plugin called `apply_filters('the_content', $post->post_content)` without priming `setup_postdata`, which made plenty of `the_content` callbacks early-return on `is_null($post)`. The Elementor / Bricks / Oxygen path was worse — those builders don't store layout in `post_content` at all, so the synced HTML was empty. The fix detects each builder via postmeta and calls its native renderer. **Sync survives shared hosting timeouts.** The 30-second PHP execution cap on shared hosts no longer fatals mid-sync on large catalogs. The resumable sync pattern (transient + WP-Cron continuation) lets a 5,000-post sync finish over multiple ticks. **Conversation cookie lifecycle.** The widget writes `pitchbar_conv_id` on init success so the plugin's `CartCouponController::applyPendingCoupon` hook can locate the correct staged coupon. Pre-v2.0 the cookie was only set when `?pitchbar_conv` arrived as a query parameter, which left chat→cart flows without a correlation key. ## New: BYOK (Bring Your Own Keys) Each workspace can now pay its own Cloudflare / OpenAI / OpenRouter / Qdrant bill. Super-admin flips a global toggle in Settings → System; customers paste their keys at Settings → AI keys. Per-user override (force-on / force-deny / inherit-global) lets you grant or block individual users independent of the global flag. Strategic for operators reselling Blengi as a SaaS — the more end-customers scale, the less you owe upstream. Workspace-level credentials are encrypted at rest via `APP_KEY` (`workspaces.byok_keys` is an `encrypted:array` cast). Five layers of tenant isolation guarantee workspace A's keys never leak to workspace B's container resolution. See `/documentation/byok` for the full matrix. ## New: file parsing routed through Cloudflare's free `toMarkdown` PDF, DOCX, DOC, XLSX, XLS, ODT, ODS uploads now flow through Cloudflare Workers AI's `toMarkdown` endpoint instead of the in-process Smalot / PhpWord parsers. Cost: zero (toMarkdown for document formats consumes 0 Neurons). Quality: structured markdown out — headings, lists, tables preserved, which feeds the chunker much better than Smalot's best-effort plain text on Word-exported or scanned-with-text-layer PDFs. CSV / MD / TXT stay on the local parsers; HTML / SVG uploads are rejected at the validator. Falls back to Smalot when Cloudflare credentials are absent (BYOK OpenAI customers, fresh installs) or when the Cloudflare call fails. ## New: sitemap fan-out + 500-page cap The default `services.crawl.max_pages_per_source` bumped from 25 → 500. Buyers adding a 100-URL sitemap silently lost 75 pages. `SitemapDiscoverer` now handles three input shapes correctly: domain root (probes `/sitemap.xml` + `/sitemap_index.xml`), direct sitemap URL (fetched verbatim — previous code appended a second `/sitemap.xml`), and `<sitemapindex>` recursion. CMSes that emit a sitemap-index by default (WordPress, Shopify, Webflow) get every page indexed instead of just the homepage. Override the cap via `CRAWL_MAX_PAGES_PER_SOURCE` in env. ## New: always-visible "Talk to a human" button The widget header carries the button at all times for any agent whose vertical includes the `ticket_escalation` capability (every preset ships with it enabled by default). Plus `HumanIntentDetector` matches ~40 intent phrases ("talk to a human", "connect me to an agent", "I need to speak with someone", …) in chat messages and short-circuits the LLM straight into the handoff flow. ## New: 2-minute wait for human handoff + notifications Clicking "Talk to a human" used to fall straight to "no one's around" when no operator was actively monitoring the dashboard. Now: the conversation ALWAYS queues and notifies, the visitor waits up to 2 minutes (`WAIT_TIMEOUT_SECONDS = 120`), and every workspace member with `live_chat_available=true` gets a database + mail notification (`HumanRequestedNotification`). Operators who had the dashboard closed still see the queued conversation in the sidebar on next sign-in. ## New: public KB articles Curated answers can now be published as public knowledge-base pages at `/kb/{workspace.slug}/{article.slug}`. Each curated answer row carries `kb_title` + `kb_published` toggle and an inline publish pill in the admin grid. KB pages render operator-supplied markdown through a hardened converter (`App\Support\SafeMarkdown`) — raw HTML stripped, `javascript:` / `vbscript:` / `data:` URIs stripped from links. ## New: Google Sheets as a knowledge source Pick a sheet tab, the crawler ingests every row and treats each as a chunk seed. Mirrors the Notion / Google Docs OAuth path. ## New: multi-currency + lifetime-deal pricing Visitors on the marketing pricing page can pick their currency; Stripe Checkout uses the matching price. Lifetime-deal plans render as a separate section under the subscription tiers. ## Security hardening **Stored XSS on public KB pages — blocked.** `Illuminate\Support\Str::markdown` defaults to `html_input='allow'` and `allow_unsafe_links=true`, which would make `<img onerror=...>` and `[click](javascript:...)` in a KB article execute as live JS on every visitor browser. New `App\Support\SafeMarkdown` helper swaps in `html_input='strip'` + `allow_unsafe_links=false`. Ten unit tests pin every payload. **SSRF guard on the crawler.** A workspace admin pasting `http://169.254.169.254/...` (AWS metadata), `http://localhost:6379/` (loopback Redis), or `http://10.0.0.5/admin` (intranet) used to walk straight into the server's internal network on the `PlainHttpCrawler` fallback. New `App\Support\UrlSafetyGuard` blocks every private / loopback / link-local CIDR, refuses non-http(s) schemes, and (on crawl jobs) DNS-resolves hostnames to catch rebind attacks where `attacker.com` points at `127.0.0.1`. `PlainHttpCrawler` also re-validates every redirect hop and disables auto-follow. **Widget Origin re-check on every privileged endpoint.** New `VerifyWidgetOrigin` middleware re-validates the request Origin against the JWT-bound agent's `allowed_origins` on every `/v1/widget/*` endpoint except `init` (which enforces inline before issuing the JWT). Defence-in-depth against stolen JWTs replayed from an unlisted origin. **Browser-level defence headers on every web response.** CSP (`default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'`), HSTS on HTTPS requests (1 year + subdomains), `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`. Scoped to the `web` middleware group — the widget API is excluded because buyers embed cross-origin. **Upload MIME allowlist.** `UploadController` requires `mimes:pdf,docx,doc,xlsx,xls,csv,md,markdown,txt,odt,ods` on every uploaded file. `.html` / `.exe` / `.svg` / `.zip` uploads get a 422 at the validator before the parser sees a byte. **Mass-assignment cleanup on `User`.** `byok_enabled` and `default_workspace_id` removed from `User::#[Fillable]`. Both are privilege-bearing — sanctioned admin paths use `forceFill` after authorisation. ## Improvements **Crawler retry policy.** `CrawlPageJob` now distinguishes rate-limited (release-with-fresh-backoff, no retry slot burned), permanent (DNS error, 4xx) — short-circuit via `fail()` — and transient (5xx) failures. Stops the `MaxAttemptsExceededException` flood that used to fill production logs whenever a customer indexed a domain with a few dead URLs. Per-job timeout 90s, `failOnTimeout=true` so timeouts still flip Source rows to `failed` with customer-readable errors. **Cloudflare Worker cron driver hardened.** `TickCommand` `--max-time` bumped 25 → 55s (the loop exits before the 60s tick boundary), `--job-timeout` default 120s, queues processed: `crawl,index,default`. Resolves the `MaxAttemptsExceededException` flood reported in production. **PDF reindex works for uploads.** `UploadController` now persists parsed segment text under `storage/app/private/uploads/{source_id}/segment-N.txt`. The Reindex button reads it back without re-uploading the original. Pre-fix, "Reindex" was a no-op on file uploads because the controller short-circuited on a null `document.url`. **PDF parser sanitization.** Strips PDF stream artifacts, control bytes, and "/Type /Page" leakage that some encoders dump into `getText()`. Falls back to full-document text when per-page extraction returns empty (common for Word-exported PDFs). Drops segments with <75% printable characters as "no extractable text" instead of indexing garbage. **`pages_total` in source preview reads `services.crawl.max_pages_per_source`.** The progress indicator on the Sources preview now reflects the current cap (500 by default) instead of the hard-coded 25 that was stale after the bump. **Cloudflare AI Gateway support.** Set `CLOUDFLARE_AI_GATEWAY_URL` to route Workers AI calls through Cloudflare's gateway — observability, caching, optional per-account rate limits. **Documentation refresh.** New `/documentation/byok` page; security, knowledge, live-chat, allowed-origins, env, and deployment pages all updated to reflect the v2.0.0 changes. ## Fixes (operator-reported during v2.0.0) **"AI keys" link surfaced to customers.** After a buyer enrolled BYOK from the admin dashboard, their customers had no way to find the `/settings/byok-keys` form because no nav entry pointed at it. Fixed by adding a conditional sidebar entry driven by the `byok.unlocked` Inertia shared prop. **"AI keys" link hidden from super_admin.** Super-admins were seeing the customer-facing BYOK menu. They manage platform-wide AI credentials at `/settings/system` instead; the workspace BYOK form is customer-only. The controller hard-404s for super_admin too as belt-and-braces. **Token-count cap precision.** Replaced naive `mb_strlen / 4` heuristics for the max-tokens budget with a Unicode-aware estimator that distinguishes ASCII (4 chars/token) from CJK (~1 char/token). The plan's `max_tokens` ceiling now applies correctly to non-English visitor messages. **Sources errors are sanitised before reaching the customer UI.** Raw Cloudflare 401 JSON envelopes used to bleed through to the Sources list. `SourceErrorPresenter` now rewrites them to short friendly lines.