Skip to content

Web Chat Developer Guide

Ed Mozley edited this page Jul 15, 2026 · 3 revisions

Web Chat: Developer Guide

The engineering detail behind the Web chat channel: the tables, the public endpoints, the security model in code, how it reuses the WhatsApp channel spine, and β€” because it's a work in progress β€” a precise resume plan for the parts still to wire.

Mental model. A web chat widget is the self-hosted twin of a WhatsApp number. It drives one messaging_channels row (channel_type = 'webchat', provider = 'freeitsm'). There is no external provider and no credentials; "sending" a reply just persists a row that the visitor's browser polls for. Everything downstream β€” ticket creation, the emails thread, the reading-pane composer, multi-tenancy routing β€” is reused unchanged. If you're touching this, the first instinct should always be "how does WhatsApp already do this?"


Data model

Four tables. webchat_widgets and webchat_conversations are live; webchat_messages exists but is only used by the (not-yet-wired) AI runtime.

webchat_widgets β€” the embed config (1:1 with a channel)

One row per widget, joined 1:1 to its messaging_channels row (channel_id, unique). Company routing and the active flag live on the channel row; this table holds only what the browser needs plus the behaviour toggles.

Column Purpose
channel_id FK β†’ messaging_channels (unique, ON DELETE CASCADE).
widget_key Public key in the embed snippet (unique). Not a secret.
allowed_origins Newline/comma list of permitted site origins. Empty = any (dev only).
greeting, accent_colour, launcher_text, offline_message Look & copy.
require_email Pre-chat name+email gate (default 1).
business_calendar_id FK β†’ sla_calendars (ON DELETE SET NULL). NULL = always open. (runtime pending)
email_when_away Email a reply to the visitor if they've left. (runtime pending)
ai_enabled, ai_mode (assist|deflect), ai_offer_agent, ai_offer_email AI answers + escalation. (runtime pending)

webchat_conversations β€” one chat = one browser token

Column Purpose
channel_id FK β†’ messaging_channels (ON DELETE CASCADE).
token The per-conversation capability (unique), held in the visitor's browser.
ticket_id Set lazily on the first message (AI-off) or on escalation (deflect). One conversation β†’ one ticket.
visitor_name, visitor_email From the pre-chat form.
visitor_ip Rate limiting only.
created_datetime, last_activity_datetime Timestamps (written with UTC_TIMESTAMP()).

webchat_messages β€” pre-ticket transcript (AI runtime only)

The chat held before (and if) a conversation becomes a ticket β€” used in AI deflect mode, where the AI can answer without ever raising a ticket. sender ∈ visitor|ai|agent|system. source_email_id is the dedup key when an analyst reply (in emails) is mirrored in so the widget can show it. Not written for plain AI-off widgets (those go straight to the ticket's emails thread).

Reused, not new

  • messaging_channels β€” the channel spine (channel_type='webchat', provider='freeitsm').
  • emails β€” inbound/outbound messages, channel='webchat', channel_id set. The analyst's canonical thread.
  • ticket_origins β€” a seeded "Web chat" origin.
  • sla_calendars (+ _hours, _holidays) β€” office hours.
  • knowledge_articles β€” the AI answer source.

The channel reuse

  • FreeitsmProvider (includes/messaging/FreeitsmProvider.php, registered in messagingProvider()): satisfies the shared MessagingProvider contract. sendMessage() does no network I/O β€” it just mints a synthetic id; the outbound emails row is what the visitor polls. This is why the existing api/messaging/send_message.php reply path works for webchat with no special-casing.
  • webchatIngestMessage() (includes/webchat/webchat.php): the webchat twin of the WhatsApp ingest. Creates/append the ticket, stores the message in emails with channel='webchat', seeds the requester from the real visitor email. Threading is by the conversation's ticket_id, not by from_address (unlike WhatsApp, whose normaliseChannelIdentifier is phone-shaped).
  • 24-hour window bypass: webchat has no provider service window, so it's exempt in both send_message.php and get_ticket_thread.php ($ticketChannel === 'webchat' β†’ always "open").
  • "Web chat" ticket origin + subject label: getChannelOriginId() and buildChannelSubject() special-case webchat β†’ 'Web chat'.

Public endpoints

All under api/webchat/, unauthenticated (a website visitor hits them), and all routed through the guard in includes/webchat/public.php.

Endpoint Method Does
config.php GET Returns the widget's public look-and-feel for widget.js.
start.php POST Creates a conversation, returns the token. No ticket yet.
send.php POST Ingests a visitor message β†’ ticket (AI-off flow, live).
poll.php GET Returns messages since after id β€” the delivery mechanism.
escalate.php POST Planned β€” "talk to a person" / "raise a ticket by email".

widget.js is a static, dependency-free file served from the same directory; it reads its own key + API base from its <script> tag and renders the launcher + panel in a Shadow DOM.

The trust model in code

includes/webchat/public.php is where the safety lives:

  • webchatPublicResolve($conn, $key) β€” loads the widget by key, checks it's active, enforces the origin allowlist, sets scoped CORS headers, or exits with JSON. Treat its return as authorised.
  • webchatRequestOrigin() β€” Origin header, falling back to the Referer origin when absent (same-origin embeds send no Origin; this bit us in the local demo where the site and FreeITSM were both http://localhost). The allowlist is a deterrent, not crypto.
  • webchatLoadConversation($conn, $token, $channelId) β€” a token is only valid for its own widget's channel; a token from widget A can't be replayed on widget B.
  • webchatRateLimitStart() / webchatRateLimitSend() β€” per-IP new conversations and per-conversation messages, per minute.

The rule to remember: the widget key only lets you start a conversation from an allowed origin. Reading or posting an existing conversation needs its token. So the key leaking (it always does β€” it's in page source) never exposes anyone's chat.

Delivery = polling

widget.js polls poll.php?after=<lastId> every ~3s. poll.php returns both directions (so a reload rebuilds the transcript), plus a closed flag from the ticket's status so the widget can show a "conversation closed" state. SSE is the obvious later upgrade; the widget already funnels all rendering through one poll(), so it's a contained change.

AI answers β€” the design (runtime pending)

The groundwork is committed (includes/knowledge/kb_ai.php, includes/webchat/ai.php); the wiring is the next build.

  • Retrieval β€” kbRetrieveArticles() + kbBuildContext() (shared, in kb_ai.php): embedding search over published knowledge_articles (falls back to all articles if no OpenAI key/embeddings). (Knowledge isn't multi-tenant yet, so no company filter β€” add one here when it is.)
  • Answer β€” webchatAiReply($conn, $question, $history) (ai.php): builds a strictly-grounded, KB-only system prompt and calls aiProviderChat() with the shared knowledge_ai provider/key. Returns {ok, answer, articles}.
  • Assist vs deflect β€”
    • Assist: ticket created on first message (current flow); AI answer posted alongside. Nothing is lost.
    • Deflect: no ticket until escalation. Visitor + AI messages live in webchat_messages. The AI always offers escalation.
  • Escalation (escalate.php, to build) β€”
    • agent: promote the conversation to a live ticket (transcript becomes the opening message), then it behaves like a normal webchat ticket.
    • email: create a ticket with an AI summary as the body and the full chat log as a .txt attachment (reuse saveChannelMediaAttachment() from the ingest), then tell the visitor it'll be answered by email.
  • Transcript unification β€” when ai_enabled, the widget reads from webchat_messages. Analyst replies (stored in emails) are mirrored into webchat_messages at poll time, deduped by source_email_id, so the visitor sees them without the ticket/emails becoming the widget's source of truth.

Office hours (runtime pending)

Reuse sla_load_calendar() from includes/sla.php. A small webchatIsOpenNow($conn, $calendarId) (to write) evaluates the calendar's weekday hours + holidays in its timezone; NULL calendar β†’ always open. When closed, send.php shows the offline message and takes the enquiry as a ticket to answer later (no live expectation).

Offline email (runtime pending)

The one genuinely mailbox-dependent piece. Direction matters: outbound goes from the company's own service-desk mailbox (the same authenticated Microsoft/Google/SMTP mailbox as email tickets) to the visitor β€” it never sends as the visitor's address. Because a webchat ticket has no source mailbox (it didn't arrive via one), the sender is the company's configured mailbox; if the company has none, the toggle can't act, and Settings should say so. Reuse the senders in api/tickets/send_email.php (sendEmailViaGraph() / the SMTP path) β€” but note getMailboxForTicket() there assumes an email ticket, so mailbox selection for a webchat ticket needs its own small resolver (company β†’ mailbox). Cannot be verified locally without sending real mail, so build it carefully and test against a real mailbox.

File map

includes/webchat/webchat.php    core helpers: key gen, embed snippet, origin parse/allow,
                                load-by-key/channel, get-or-create user, webchatIngestMessage
includes/webchat/public.php     the public guard: resolve, origin (+Referer), token load, rate limits
includes/webchat/ai.php         webchatAiReply() (groundwork, not yet called)
includes/knowledge/kb_ai.php    shared KB retrieval (embedding search + context)
includes/messaging/FreeitsmProvider.php   no-op provider for the shared reply path
api/webchat/config|start|send|poll.php     public endpoints (escalate.php = to build)
api/webchat/widget.js           the embeddable widget (Shadow DOM)
api/webchat/get_widgets|save_widget|delete_widget.php   authed settings API (Cap::TICKETS_WEBCHAT)
tickets/settings/index.php       the Web chat settings tab + modal
database/freeitsm.sql Β· api/system/db_verify.php   schema (4 tables) + Web chat origin seed

Resume plan (what's left to wire)

In build order β€” each is testable locally except offline email:

  1. webchat.php runtime helpers: webchatIsOpenNow(), webchatAddMessage(), webchatGetMessages(), webchatTranscriptText(), webchatMirrorAgentReplies(), webchatPromoteToTicket() (+ an AI summary helper in ai.php).
  2. config.php: also expose ai_enabled, ai_offer_agent, ai_offer_email, and the current open/closed state + offline message, so the widget can render the right UI.
  3. send.php: branch on open/closed β†’ AI (assist/deflect) β†’ fall back to the current ingest. In AI mode, write to webchat_messages and return the AI answer.
  4. escalate.php (new): agent and email routes via webchatPromoteToTicket().
  5. poll.php: for ai_enabled conversations, webchatMirrorAgentReplies() then read webchat_messages; else the current emails read (unchanged).
  6. widget.js: AI answer bubbles, the two escalation buttons, and the offline/closed state.
  7. Offline email (mailbox-dependent β€” do last, test against a real mailbox).
  8. Changelog + README + git push; run Database Verify.

The AI provider is configured locally (OpenRouter, ~20 embedded articles), so steps 1–6 can be driven end-to-end with the local Dream Holidays demo site (c:\wamp64\www\dream-holidays\, not in git).

Gotchas

  • Same-origin sends no Origin header β†’ use webchatRequestOrigin() (Referer fallback), never $_SERVER['HTTP_ORIGIN'] directly.
  • A PHP fatal is served as HTTP 200 β€” always read the response body when testing, not just the status.
  • Testing POSTs with curl: use --data-raw; a bare -d mangled a JSON payload during development.
  • The panel is fixed-height β€” the message list must be a flex column with min-height:0 or the composer gets pushed off the bottom (already fixed; don't regress).
  • After any schema change here, update both freeitsm.sql and db_verify.php, then run Database Verify.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally