Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [main]
branches: [main, feature/phase1-5]
pull_request:
branches: [main]
branches: [main, feature/phase1-5]

jobs:
build-test:
Expand All @@ -22,6 +22,10 @@ jobs:
with:
node-version: 22

- name: Widget tests
run: npm ci && npm test
working-directory: npm/botwire-js

- name: Build (Release, warnings as errors)
run: dotnet build BotWire.slnx -c Release

Expand Down
53 changes: 53 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,56 @@ jobs:
args+=(--notes-file "$notes")
fi
gh release create "$GITHUB_REF_NAME" artifacts/*.nupkg artifacts/*.snupkg "${args[@]}"

npm:
needs: release # only publish the JS SDK once the .NET release succeeded
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance (public-repo supply-chain attestation)
defaults:
run:
working-directory: npm/botwire-js
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Verify tag matches package.json version
run: |
VERSION="${GITHUB_REF_NAME#v}"
PKG=$(node -p "require('./package.json').version")
if [ "$VERSION" != "$PKG" ]; then
echo "Tag $GITHUB_REF_NAME does not match botwire-js version $PKG in package.json" >&2
exit 1
fi

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
registry-url: https://registry.npmjs.org

- name: Install
run: npm ci

- name: Build (SDK + widget)
run: npm run build

- name: Test
run: npm test

# Trusted Publishing (OIDC) needs a newer npm than Node 22 ships with.
- name: Upgrade npm
run: npm install -g npm@latest

# No token: authenticates via OIDC against the trusted publisher configured
# for botwire-js on npmjs.com. Provenance is attached automatically.
# Idempotent: npm has no --skip-duplicate, so skip if the version already
# exists (e.g. a manually bootstrapped first publish) to keep re-runs green.
- name: Publish to npm (Trusted Publishing, OIDC)
run: |
VERSION=$(node -p "require('./package.json').version")
if npm view "botwire-js@$VERSION" version >/dev/null 2>&1; then
echo "botwire-js@$VERSION already published; skipping."
else
npm publish --access public
fi
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<LangVersion>latest</LangVersion>

<!-- Version -->
<VersionPrefix>0.1.0</VersionPrefix>
<VersionPrefix>0.2.0</VersionPrefix>

<!-- NuGet metadata -->
<Authors>Object IT Limited</Authors>
Expand Down
48 changes: 48 additions & 0 deletions NUGET.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# BotWire

**A 24/7 AI customer-support bot for your .NET app — self-hosted, bring-your-own-key, no SaaS fees.**

Drop one package into your ASP.NET Core site, point it at your FAQ, and ship a support assistant that answers customers instantly from *your* docs — and quietly opens a human ticket the moment one is actually needed. You supply an OpenAI-compatible API key, so your only running cost is model tokens (pennies per conversation on `gpt-4o-mini` or DeepSeek).

## Two lines to wire it up

```csharp
builder.Services.AddBotWire(opts =>
{
opts.TopicDescription = "Online store customer support";
opts.Documents = ["docs/faq.md"];
opts.ChatProvider = new OpenAIProviderOptions { ApiKey = "sk-...", Model = "gpt-4o-mini" };
});

app.MapBotWire();
```

Embed the zero-dependency widget on any page:

```html
<script src="/botwire/widget.js"></script>
<botwire-widget data-endpoint="/support" data-title="Acme Support"></botwire-widget>
```

## What you get

- **Grounded answers.** Replies come only from the Markdown knowledge base you supply — no hallucinated policies, prices, or promises.
- **Knows when to get a human.** Collects contact details and raises a support ticket (emailed to your team) instead of guessing.
- **Streaming chat widget.** ~12 KB Web Component, Shadow DOM, no framework, one `<script>` tag.
- **Multilingual.** Replies in the customer's language; you pick the language your team reads tickets in.
- **Production guards.** PII + prompt-injection filters, five-dimension rate limiting (concurrency, per-minute, per-session, per-IP/hour, daily token budget), and an optional NDJSON audit log.
- **Bring your own model.** OpenAI, DeepSeek, Groq, or any OpenAI-compatible endpoint.

## Packages

| Package | Use it for |
|---|---|
| **BotWire.AspNetCore** | The full stack — start here. Endpoints, widget, guards, DI. |
| **BotWire.Core** | The engine alone (RAG, escalation, guards) with no ASP.NET dependency. |
| **BotWire.Channels.Email** | SMTP delivery of escalated tickets (MailKit). |

## License

AGPL-3.0-or-later. Commercial licenses available for proprietary use.

📖 **Full docs, configuration reference, and screenshots:** https://github.com/adamy/BotWire
104 changes: 101 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,12 @@ That's it — the bot answers from `docs/faq.md` and raises tickets when it can'

| Property | Default | Description |
|---|---|---|
| `TopicDescription` | *(required)* | Short phrase describing your support scope, injected into the system prompt. |
| `TopicDescription` | *(required)* | Short phrase describing your support scope, injected into the system prompt. Also enables the off-topic guard. |
| `Documents` | `[]` | Paths to Markdown knowledge-base files. |
| `ChatProvider` | *(required)* | LLM provider — `ApiKey`, `Model`, optional `BaseUrl` for OpenAI-compatible APIs (e.g. DeepSeek). |
| `ChatProvider` | *(required)* | LLM provider — `ApiKey`, `Model`, optional `BaseUrl` for OpenAI-compatible APIs (e.g. DeepSeek), optional `Temperature`. |
| `ChatProvider.Temperature` | `0.2` | Sampling temperature. Low by default for consistent, grounded answers. Set `null` to omit it and use the provider's own default. |
| `MaxAnswerAttempts` | `3` | How many times to retry an empty/invalid model reply within one turn before handing off to a human. |
| `OffTopicResponse` | *(built-in)* | Reply shown when the off-topic guard classifies a message outside your support scope. |
| `MaxMessageLength` | `2000` | Max user message length in characters. |
| `MaxRequestsPerIpPerMinute` | `20` | IP rate-limit cap. |
| `SessionTtl` | `2 hours` | Idle session lifetime. |
Expand All @@ -97,7 +100,27 @@ builder.Services.AddSingleton<ISystemPromptBuilder, MyPromptBuilder>();
builder.Services.AddBotWire(opts => { ... });
```

`ISystemPromptBuilder.Build(documents)` receives the loaded knowledge-base document contents and must return the complete system prompt. Your implementation **must** preserve the control-word contract: the model's first line must be either `ANSWER` or `ESCALATE` on its own line, otherwise escalation and ticket creation stop working.
`ISystemPromptBuilder.Build(documents)` receives the loaded knowledge-base document contents and must return the complete system prompt. Your implementation **must** preserve the response contract described below, otherwise escalation, off-topic handling, and ticket creation stop working.

### AI response protocol

The model is required to reply with a single JSON object and nothing else:

```json
{ "offtopic": false, "action": "answer", "message": "..." }
```

- **`action`** — `"answer"` (the `message` is shown to the customer) or `"escalate"` (BotWire runs its standard contact-collection flow and ignores `message`).
- **`offtopic`** — present only when `TopicDescription` is set. When `true`, BotWire shows `OffTopicResponse` instead of the model's text.
- **`message`** — the customer-facing reply, in the customer's own language.

This is requested via the provider's `response_format: json_object` mode. Field order matters for streaming: BotWire's incremental reader waits until `offtopic`/`action` have arrived before it starts streaming `message` token by token, so the customer never briefly sees an escalation that then disappears. If the model reorders the fields and emits `message` first, the reader streams it as a plain answer.

**Retry and hand-off.** If a reply is not valid JSON, or carries an empty/whitespace-only `message`, BotWire retries up to `MaxAnswerAttempts` times (default 3) within the same turn. Nothing is shown to the customer between attempts. If every attempt is unusable, BotWire escalates to a human rather than show a blank answer.

**Conversation history.** BotWire keeps each assistant turn in two forms: the readable text for ticket generation, and the **raw JSON envelope** in the history it sends back to the model. Replaying the envelope (rather than bare text) keeps the model anchored to the JSON format across long conversations — feeding it plain-text history teaches it to drop the format and reply with bare text or whitespace on later turns. A custom `ISystemPromptBuilder` does not need to handle this; it is internal to the answer pipeline.

A low `ChatProvider.Temperature` (default `0.2`) further stabilises format adherence and keeps grounded answers consistent.

### Ticket language

Expand Down Expand Up @@ -132,6 +155,81 @@ Register your own `IEmailTemplateFormatter` to control how tickets are formatted
builder.Services.AddSingleton<IEmailTemplateFormatter, MyEmailFormatter>();
```

### Audit log

BotWire can emit business/compliance events (messages, guard blocks, escalations, rate-limit
hits, errors) to a write-only audit sink — separate from application logging (`ILogger<T>`).
The default is a no-op. Write newline-delimited JSON (NDJSON) under a root folder — one file per
session, bucketed by UTC date (`{root}/{yyyyMMdd}/{sessionId}.ndjson`):

```csharp
builder.Services.AddBotWire(opts => { /* ... */ })
.AddJsonAuditLog("logs/audit");
```

Each line is a self-contained JSON object, e.g. in `logs/audit/20260611/u-123.ndjson`:

```jsonl
{"ts":"2026-06-11T10:00:00+00:00","event":"message","sessionId":"u-123","role":"user","content":"refund?"}
{"ts":"2026-06-11T10:00:01+00:00","event":"escalated","sessionId":"u-123","reason":"NEED_HUMAN","ticketId":"TKT-20260611-0042"}
```

Files are opened for shared reading (`tail -f`-friendly) and concurrent writes are serialised.
BotWire only writes; querying is up to you. To send events elsewhere (database, queue), register
your own `IAuditLogger`:

```csharp
builder.Services.AddSingleton<IAuditLogger, MyAuditLogger>();
```

## Deploying behind a reverse proxy or CDN

If your site sits behind a reverse proxy or CDN (Cloudflare, nginx, IIS ARR, …), the
connection BotWire sees comes from the proxy, not the visitor. Two things need attention:

### Restore the real client IP

BotWire's rate limiter keys on the client IP. Behind a proxy every visitor appears to
come from a handful of proxy IPs, so legitimate traffic can trip the limiter (HTTP 429).
Enable ASP.NET Core's forwarded-headers middleware **before** `MapBotWire()`:

```csharp
using Microsoft.AspNetCore.HttpOverrides;

builder.Services.Configure<ForwardedHeadersOptions>(opts =>
{
opts.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// Trust your proxy. For Cloudflare, add its published IP ranges to KnownNetworks,
// or clear the lists if an upstream firewall already guarantees the source.
opts.KnownNetworks.Clear();
opts.KnownProxies.Clear();
});

app.UseForwardedHeaders();
```

Cloudflare also sends the original visitor IP in the `CF-Connecting-IP` header; if you
prefer that over `X-Forwarded-For`, copy it into `X-Forwarded-For` at your origin server
or use Cloudflare's [authenticated origin pulls + restore-IP guidance](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/).

### Cloudflare-specific settings

- **Bypass cache for `/support/*`** — chat responses are per-visitor and streamed; add a
cache rule that bypasses cache for `/support/*` so Cloudflare never caches or buffers them.
- **Disable Rocket Loader** for pages embedding the widget — it defers script execution
and can break the widget's custom element registration.
- **Use Full (strict) SSL** — Flexible SSL terminates TLS at the edge and connects to your
origin over HTTP, which can cause redirect loops and mixed-content issues with SSE.

### Session tokens and process restarts

The default session store (`AddInMemoryConversationStore`) lives in process memory. On
shared hosting, app-pool recycles or redeploys clear it, so browsers may hold a stale
session token. The widget handles this automatically: a rejected token
(`status: "InvalidSession"`) makes it create a fresh session and resend the message once,
invisibly to the user. Custom clients should do the same — see the
`status` field on the 400 response.

## Running tests

### Unit tests (no API key needed)
Expand Down
45 changes: 45 additions & 0 deletions docs/release-notes/v0.2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## BotWire v0.2.0 — Production Hardening

The Phase 1.5 release: everything from v0.1.0 plus the guardrails, observability, and a framework-agnostic JavaScript SDK that make BotWire ready for real traffic. Still one-line integration, still bring-your-own-key.

### New features

- **Five-dimension rate limiting** — independently configurable, each disabled at `0`:
- `MaxConcurrentSessions` — queue and wait for a slot (never reject)
- `MaxMessagesPerMinute` — per-session throttle that *delays* rather than errors
- `MaxMessagesPerSession` — prompt the user to start a new conversation
- `MaxSessionsPerIpPerHour` — reject new sessions over the cap
- `DailyTokenBudget` — degraded response once the daily token budget is spent

```csharp
opts.RateLimiting = new RateLimitOptions { DailyTokenBudget = 500_000 };
```

- **Token accounting** — real provider usage (`TotalTokenCount`, with a char-based estimate fallback) now flows through every turn, feeds the daily budget, and is recorded on `message` and `escalated` audit entries.
- **Audit log** — opt-in NDJSON business/compliance trail (messages, guard blocks, escalations, rate-limit hits, errors), one file per session bucketed by UTC date. Write your own `IAuditLogger` to send events elsewhere.

```csharp
builder.Services.AddBotWire(opts => { /* ... */ }).AddJsonAuditLog("logs/audit");
```

- **Session summary compression** — long conversations fold their oldest turns into a rolling LLM summary to cap token cost, while the full history is preserved for ticket generation.
- **Topic / off-topic classification** — folded into the main answer JSON (`{ offtopic, action, message }`), no extra model call.
- **`botwire-js` npm package** — framework-agnostic JS/TS client (chat, SSE streaming, session management, zero DOM deps). The embedded widget is now built on top of it.
- **Widget enhancements** — conversation starters, a conversation-reset button, off-topic/blocked handling, and `data-lang` multilingual config.
- **Session self-heal** — a stale session token is transparently rebuilt and the message resent once, invisible to the user.

### Breaking changes

- `ILlmChatClient` now reports token usage. `ChatAsync` returns `LlmChatResult` (text + tokens) instead of `string`, and `ChatStreamingAsync` takes an `Action<int>? onUsage` callback. **Custom `ILlmChatClient` implementations must update their signatures.** No change is needed if you use the built-in OpenAI client.

### Notes

- Rate-limit and token-budget counters are in-memory and per-process (they reset on restart). Durable, cross-instance budgeting (Redis) is planned for a later release.

### Get started

```
dotnet add package BotWire.AspNetCore
```

See the [README](https://github.com/adamy/BotWire#readme) for the full quick start and configuration reference.
Loading