@freewaretools/outercom 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts ADDED
@@ -0,0 +1,363 @@
1
+ import type { ChatStorage } from "./storage"
2
+ import type { ChatTransport } from "./transport"
3
+ import type {
4
+ AIProvider,
5
+ ChatMessage,
6
+ ChatRole,
7
+ ChatSession,
8
+ ChatVisitor,
9
+ } from "./types"
10
+
11
+ export interface ChatEngineOptions {
12
+ storage: ChatStorage
13
+ transport: ChatTransport
14
+ /** Optional first-line AI. Omit (or pass `aiEnabled: false`) for human-only chat. */
15
+ ai?: AIProvider
16
+ aiEnabled?: boolean
17
+ /** Builds the forum-topic title for a new chat. Default: "Name · email". */
18
+ topicName?: (visitor: ChatVisitor) => string
19
+ /** Message shown to the visitor when a chat is escalated to a human. */
20
+ escalationMessage?: string
21
+ /**
22
+ * Optional hook to email/send a transcript. Called when a chat is closed and
23
+ * when the visitor requests a copy. Host-provided (e.g. render + Resend) so
24
+ * chatkit stays mailer-agnostic.
25
+ */
26
+ sendTranscript?: (session: ChatSession, messages: ChatMessage[]) => Promise<void>
27
+ /**
28
+ * Optional hook to email a visitor replies they haven't seen (because they
29
+ * disconnected). Called by {@link flushUnseen}. Host-provided.
30
+ */
31
+ sendReplyNotification?: (
32
+ session: ChatSession,
33
+ unseen: ChatMessage[]
34
+ ) => Promise<void>
35
+ }
36
+
37
+ const DEFAULT_ESCALATION =
38
+ "I've passed this to our team — someone will jump in here shortly. Feel free to add any extra detail in the meantime."
39
+
40
+ /** Messages the client should render (everything except the visitor's own echoes). */
41
+ const CLIENT_ROLES: ChatRole[] = ["ai", "agent", "system"]
42
+
43
+ /**
44
+ * Orchestrates a conversation across the storage, Telegram transport, and the
45
+ * optional AI first-line. Transport-agnostic at heart — `transport` is the only
46
+ * Telegram-specific dependency and sits behind a small surface.
47
+ */
48
+ export class ChatEngine {
49
+ private readonly storage: ChatStorage
50
+ private readonly transport: ChatTransport
51
+ private readonly ai?: AIProvider
52
+ private readonly aiEnabled: boolean
53
+ private readonly topicName: (v: ChatVisitor) => string
54
+ private readonly escalationMessage: string
55
+ private readonly sendTranscript?: (
56
+ session: ChatSession,
57
+ messages: ChatMessage[]
58
+ ) => Promise<void>
59
+ private readonly sendReplyNotification?: (
60
+ session: ChatSession,
61
+ unseen: ChatMessage[]
62
+ ) => Promise<void>
63
+
64
+ constructor(opts: ChatEngineOptions) {
65
+ this.storage = opts.storage
66
+ this.transport = opts.transport
67
+ this.ai = opts.ai
68
+ this.aiEnabled = (opts.aiEnabled ?? true) && !!opts.ai
69
+ this.topicName =
70
+ opts.topicName ?? ((v) => `${v.name} · ${v.email}`)
71
+ this.escalationMessage = opts.escalationMessage ?? DEFAULT_ESCALATION
72
+ this.sendTranscript = opts.sendTranscript
73
+ this.sendReplyNotification = opts.sendReplyNotification
74
+ }
75
+
76
+ /** Email the visitor a transcript on demand. Returns false if no mailer is wired. */
77
+ async emailTranscript(sessionId: string): Promise<boolean> {
78
+ if (!this.sendTranscript) return false
79
+ const session = await this.storage.getSession(sessionId)
80
+ if (!session) return false
81
+ try {
82
+ const messages = await this.storage.getMessages(sessionId)
83
+ await this.sendTranscript(session, messages)
84
+ return true
85
+ } catch (err) {
86
+ console.error("[chatkit] sendTranscript failed:", err)
87
+ return false
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Start a new chat: create the session, open a Telegram topic, and post the
93
+ * captured visitor details. The pre-chat form has already collected name +
94
+ * email (+ optional question) so a topic-per-chat is created up front.
95
+ */
96
+ async startSession(visitor: ChatVisitor): Promise<{ sessionId: string }> {
97
+ const id = crypto.randomUUID()
98
+ const now = Date.now()
99
+
100
+ // Reuse one topic per customer email; reopen it if a prior chat closed it.
101
+ let threadId = await this.storage.getThreadByEmail(visitor.email)
102
+ const reused = threadId !== null
103
+ if (threadId !== null) {
104
+ await this.transport.reopenTopic(threadId)
105
+ } else {
106
+ try {
107
+ threadId = await this.transport.createTopic(this.topicName(visitor))
108
+ } catch (err) {
109
+ // If topic creation fails (misconfigured group/perms), the chat still
110
+ // works AI-only; agent relay just won't be available until fixed.
111
+ console.error("[chatkit] could not create transport thread:", err)
112
+ threadId = null
113
+ }
114
+ }
115
+
116
+ const session: ChatSession = {
117
+ id,
118
+ visitor,
119
+ threadId,
120
+ status: this.aiEnabled ? "ai" : "live",
121
+ createdAt: now,
122
+ updatedAt: now,
123
+ }
124
+ await this.storage.createSession(session)
125
+
126
+ // Post the Telegram intro + handle the opening question in the BACKGROUND so
127
+ // "Start chat" returns immediately. Awaiting these blocked the widget on two
128
+ // Telegram round-trips plus the full AI generation. The widget shows the
129
+ // opening message optimistically and the AI reply arrives via poll().
130
+ void (async () => {
131
+ try {
132
+ if (threadId != null) {
133
+ const header = reused
134
+ ? "🔁 <b>Returning visitor — new chat</b>"
135
+ : "🆕 <b>New chat</b>"
136
+ const intro =
137
+ `${header}\n` +
138
+ `From: ${esc(visitor.name)} (${esc(visitor.email)})\n` +
139
+ (visitor.topic ? `Topic: ${esc(visitor.topic)}\n` : "") +
140
+ (this.aiEnabled
141
+ ? `\nThe AI assistant is handling this. Reply in this topic to take over.`
142
+ : `\nReply in this topic to respond.`)
143
+ await this.transport.sendMessage(threadId, intro)
144
+ }
145
+ // The pre-chat question becomes the visitor's opening message.
146
+ if (visitor.topic) {
147
+ await this.handleVisitorMessage(id, visitor.topic)
148
+ }
149
+ } catch (err) {
150
+ console.error("[chatkit] post-start tasks failed:", err)
151
+ }
152
+ })()
153
+
154
+ return { sessionId: id }
155
+ }
156
+
157
+ /**
158
+ * Handle a visitor message: persist + mirror to Telegram, then (if the AI is
159
+ * handling) generate a reply. Returns any new client-facing messages produced
160
+ * synchronously (the AI reply / escalation note) so the widget can render
161
+ * them immediately; async agent replies arrive via {@link poll}.
162
+ */
163
+ async handleVisitorMessage(
164
+ sessionId: string,
165
+ text: string
166
+ ): Promise<{ session: ChatSession; newMessages: ChatMessage[] }> {
167
+ const session = await this.requireSession(sessionId)
168
+ const clean = text.trim()
169
+ if (!clean) return { session, newMessages: [] }
170
+
171
+ await this.append(session, "visitor", clean)
172
+ await this.mirror(session, `💬 <b>${esc(session.visitor.name)}:</b> ${esc(clean)}`)
173
+
174
+ const produced: ChatMessage[] = []
175
+
176
+ if (session.status === "ai" && this.aiEnabled && this.ai) {
177
+ try {
178
+ const history = await this.storage.getMessages(sessionId)
179
+ const ai = await this.ai.reply(history, session.visitor)
180
+
181
+ if (ai.escalate) {
182
+ await this.mirror(
183
+ session,
184
+ `🙋 <b>AI requested a human</b>${ai.reason ? ` — ${esc(ai.reason)}` : ""}`
185
+ )
186
+ const note = await this.append(session, "system", this.escalationMessage)
187
+ produced.push(note)
188
+ await this.storage.updateSession(sessionId, { status: "live" })
189
+ } else {
190
+ const reply = await this.append(session, "ai", ai.text)
191
+ produced.push(reply)
192
+ await this.mirror(session, `🤖 <b>AI:</b> ${esc(ai.text)}`)
193
+ }
194
+ } catch (err) {
195
+ console.error("[chatkit] AI reply failed, escalating:", err)
196
+ const note = await this.append(session, "system", this.escalationMessage)
197
+ produced.push(note)
198
+ await this.storage.updateSession(sessionId, { status: "live" })
199
+ await this.mirror(session, `⚠️ <b>AI error — needs a human.</b>`)
200
+ }
201
+ }
202
+
203
+ return { session: await this.requireSession(sessionId), newMessages: produced }
204
+ }
205
+
206
+ /**
207
+ * Handle an inbound Telegram webhook update — a human agent replying in a
208
+ * topic. Flips the session to `live` so the AI stays quiet, or closes it on
209
+ * `/close`. Returns true if the update mapped to a known session.
210
+ */
211
+ async handleWebhookUpdate(update: unknown): Promise<boolean> {
212
+ const parsed = this.transport.parseUpdate(update)
213
+ if (!parsed) return false
214
+
215
+ const session = await this.storage.getSessionByThread(parsed.threadId)
216
+ if (!session) return false
217
+
218
+ if (parsed.command === "close") {
219
+ await this.closeSession(session.id)
220
+ return true
221
+ }
222
+
223
+ await this.append(session, "agent", parsed.text, parsed.fromName)
224
+ if (session.status !== "live") {
225
+ await this.storage.updateSession(session.id, { status: "live" })
226
+ }
227
+ return true
228
+ }
229
+
230
+ /**
231
+ * Poll for messages the visitor hasn't seen yet, plus the live status. Also
232
+ * records the visitor as "seen" now (a present visitor is polling/heartbeating)
233
+ * — the disconnect detector keys off this.
234
+ */
235
+ async poll(
236
+ sessionId: string,
237
+ after = 0
238
+ ): Promise<{ status: ChatSession["status"]; messages: ChatMessage[] }> {
239
+ const session = await this.requireSession(sessionId)
240
+ const messages = await this.storage.getMessages(sessionId, {
241
+ after,
242
+ roles: CLIENT_ROLES,
243
+ })
244
+ if (session.status !== "closed") {
245
+ await this.storage.updateSession(sessionId, { lastSeenAt: Date.now() })
246
+ }
247
+ return { status: session.status, messages }
248
+ }
249
+
250
+ /**
251
+ * Email visitors who disconnected (no poll for >= thresholdMs) the agent/AI
252
+ * replies they never saw. Idempotent via `notifiedAt`. Call from a cron.
253
+ * Returns how many sessions were emailed.
254
+ */
255
+ async flushUnseen(thresholdMs: number): Promise<number> {
256
+ if (!this.sendReplyNotification || thresholdMs <= 0) return 0
257
+ const now = Date.now()
258
+ const idle = await this.storage.getSessionsIdleSince(now - thresholdMs)
259
+ let sent = 0
260
+ for (const session of idle) {
261
+ const since = Math.max(session.lastSeenAt ?? 0, session.notifiedAt ?? 0)
262
+ const unseen = await this.storage.getMessages(session.id, {
263
+ after: since,
264
+ roles: ["ai", "agent"],
265
+ })
266
+ if (unseen.length === 0) continue
267
+ try {
268
+ await this.sendReplyNotification(session, unseen)
269
+ await this.storage.updateSession(session.id, { notifiedAt: now })
270
+ sent++
271
+ } catch (err) {
272
+ console.error("[chatkit] reply notification failed:", err)
273
+ }
274
+ }
275
+ return sent
276
+ }
277
+
278
+ /**
279
+ * The visitor ended/reset the chat from their side. Post a notice into the
280
+ * topic so agents know replies won't reach them, then close it. No transcript
281
+ * email (the visitor chose to start over, not to wrap up).
282
+ */
283
+ async abandonSession(sessionId: string): Promise<void> {
284
+ const session = await this.storage.getSession(sessionId)
285
+ if (!session || session.status === "closed") return
286
+ await this.mirror(
287
+ session,
288
+ "🚪 <b>The visitor left / reset the chat.</b> Replies here won't reach them."
289
+ )
290
+ await this.storage.updateSession(sessionId, { status: "closed" })
291
+ if (session.threadId != null) {
292
+ await this.transport.closeTopic(session.threadId)
293
+ }
294
+ }
295
+
296
+ async closeSession(sessionId: string): Promise<void> {
297
+ const session = await this.storage.getSession(sessionId)
298
+ if (!session) return
299
+ const alreadyClosed = session.status === "closed"
300
+ if (!alreadyClosed) {
301
+ await this.append(session, "system", "This chat has been closed. Thanks for getting in touch!")
302
+ }
303
+ await this.storage.updateSession(sessionId, { status: "closed" })
304
+
305
+ // Email the transcript once, on the transition to closed.
306
+ if (!alreadyClosed && this.sendTranscript) {
307
+ try {
308
+ const messages = await this.storage.getMessages(sessionId)
309
+ await this.sendTranscript(session, messages)
310
+ } catch (err) {
311
+ console.error("[chatkit] transcript on close failed:", err)
312
+ }
313
+ }
314
+
315
+ if (session.threadId != null) {
316
+ await this.transport.closeTopic(session.threadId)
317
+ }
318
+ }
319
+
320
+ // --- internals -----------------------------------------------------------
321
+
322
+ private async requireSession(id: string): Promise<ChatSession> {
323
+ const s = await this.storage.getSession(id)
324
+ if (!s) throw new ChatSessionNotFound(id)
325
+ return s
326
+ }
327
+
328
+ private async append(
329
+ session: ChatSession,
330
+ role: ChatRole,
331
+ text: string,
332
+ authorName?: string
333
+ ): Promise<ChatMessage> {
334
+ const message: ChatMessage = {
335
+ id: crypto.randomUUID(),
336
+ sessionId: session.id,
337
+ role,
338
+ text,
339
+ createdAt: Date.now(),
340
+ ...(authorName ? { authorName } : {}),
341
+ }
342
+ await this.storage.appendMessage(message)
343
+ return message
344
+ }
345
+
346
+ private async mirror(session: ChatSession, html: string): Promise<void> {
347
+ if (session.threadId != null) {
348
+ await this.transport.sendMessage(session.threadId, html)
349
+ }
350
+ }
351
+ }
352
+
353
+ export class ChatSessionNotFound extends Error {
354
+ constructor(public readonly sessionId: string) {
355
+ super(`chat session not found: ${sessionId}`)
356
+ this.name = "ChatSessionNotFound"
357
+ }
358
+ }
359
+
360
+ /** Escape text for Telegram HTML parse mode. */
361
+ function esc(s: string): string {
362
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
363
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * chatkit — a portable, self-hosted customer-chat module.
3
+ *
4
+ * A floating chat widget that routes conversations to wherever your team works
5
+ * (Telegram in v1; the transport seam makes Slack/Discord/email pluggable),
6
+ * with an optional Claude first-line that deflects common questions and
7
+ * escalates to a human when needed.
8
+ *
9
+ * This is the framework-agnostic core. Entry points:
10
+ * - `chatkit` → core (this file)
11
+ * - `chatkit/next` → Next.js App Router route factories
12
+ * - `chatkit/react` → the widget component
13
+ *
14
+ * The folder has no host-app imports, so it can be lifted into its own package.
15
+ */
16
+
17
+ export * from "./types"
18
+ export * from "./storage"
19
+ export * from "./transport"
20
+ export * from "./telegram"
21
+ export * from "./ai"
22
+ export * from "./local"
23
+ export * from "./transcript"
24
+ export * from "./engine"
package/src/local.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { AIProvider, ChatMessage, ChatVisitor } from "./types"
2
+ import { buildSupportSystemPrompt, parseAIReply, toChatTurns } from "./prompt"
3
+
4
+ export interface LocalProviderConfig {
5
+ /**
6
+ * OpenAI-compatible base URL (ending in `/v1`). Works with any server that
7
+ * speaks the Chat Completions API:
8
+ * - Ollama → http://localhost:11434/v1
9
+ * - LM Studio → http://localhost:1234/v1
10
+ * - llama.cpp → http://localhost:8080/v1
11
+ * - vLLM / TGI → http://localhost:8000/v1
12
+ * From inside a Docker container, point at the host (e.g.
13
+ * http://host.docker.internal:11434/v1) or run the model in the same network.
14
+ */
15
+ baseUrl: string
16
+ /** Model name as the server knows it (e.g. "llama3.1:8b", "qwen2.5:7b-instruct"). */
17
+ model: string
18
+ /** Optional bearer token (some servers require any non-empty value). */
19
+ apiKey?: string
20
+ knowledgeBase: string
21
+ brand?: string
22
+ systemPromptExtra?: string
23
+ maxTokens?: number
24
+ /** Low by default for reliable JSON. */
25
+ temperature?: number
26
+ }
27
+
28
+ /**
29
+ * Local / self-hosted first-line responder over the OpenAI-compatible Chat
30
+ * Completions API. Same persona + JSON contract as {@link AnthropicProvider};
31
+ * keeps all chat data on your own infrastructure with no per-token cost.
32
+ *
33
+ * Smaller local models can be shakier at strict JSON, but `parseAIReply` falls
34
+ * back to treating the whole response as the reply, so a chat never breaks.
35
+ */
36
+ export class LocalProvider implements AIProvider {
37
+ private readonly endpoint: string
38
+
39
+ constructor(private readonly cfg: LocalProviderConfig) {
40
+ this.endpoint = `${cfg.baseUrl.replace(/\/+$/, "")}/chat/completions`
41
+ }
42
+
43
+ async reply(history: ChatMessage[], visitor: ChatVisitor) {
44
+ const turns = toChatTurns(history)
45
+ if (turns.length === 0 || turns[0].role !== "user") {
46
+ return { text: "Thanks for reaching out — how can I help?", escalate: false }
47
+ }
48
+
49
+ const res = await fetch(this.endpoint, {
50
+ method: "POST",
51
+ headers: {
52
+ "Content-Type": "application/json",
53
+ ...(this.cfg.apiKey ? { Authorization: `Bearer ${this.cfg.apiKey}` } : {}),
54
+ },
55
+ body: JSON.stringify({
56
+ model: this.cfg.model,
57
+ max_tokens: this.cfg.maxTokens ?? 1024,
58
+ temperature: this.cfg.temperature ?? 0.2,
59
+ stream: false,
60
+ messages: [
61
+ { role: "system", content: buildSupportSystemPrompt(this.cfg, visitor) },
62
+ ...turns,
63
+ ],
64
+ }),
65
+ })
66
+
67
+ if (!res.ok) {
68
+ throw new Error(`local LLM request failed: ${res.status} ${res.statusText}`)
69
+ }
70
+
71
+ const data = (await res.json()) as {
72
+ choices?: Array<{ message?: { content?: string } }>
73
+ }
74
+ const raw = data.choices?.[0]?.message?.content?.trim() ?? ""
75
+ return parseAIReply(raw)
76
+ }
77
+ }
package/src/next.ts ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Next.js App Router adapter. Wire these into `route.ts` files:
3
+ *
4
+ * // src/app/api/chat/route.ts
5
+ * import { routes } from "@/lib/chat/config"
6
+ * export const runtime = "nodejs"
7
+ * export const POST = routes.chat.POST
8
+ *
9
+ * Uses only the Web `Request`/`Response` APIs, so it isn't tied to Next beyond
10
+ * the file convention.
11
+ */
12
+ import { ChatEngine, ChatSessionNotFound } from "./engine"
13
+ import type { ChatVisitor } from "./types"
14
+
15
+ type EngineResolver = () => ChatEngine | Promise<ChatEngine>
16
+
17
+ export interface ChatRouteOptions {
18
+ /** Shared secret to verify inbound transport webhooks. */
19
+ webhookSecret?: string
20
+ /** Header that carries the secret. Defaults to Telegram's. */
21
+ webhookSecretHeader?: string
22
+ /** Max length of a single visitor message. */
23
+ maxMessageLength?: number
24
+ /**
25
+ * Derive the visitor server-side (e.g. from the auth session). When set, the
26
+ * `start` action uses this identity and IGNORES any client-sent name/email —
27
+ * use it for authenticated apps so identity can't be spoofed. Return null to
28
+ * reject the request (401). A client-sent `topic` is still honoured.
29
+ */
30
+ resolveVisitor?: (req: Request) => Promise<{ name: string; email: string } | null>
31
+ }
32
+
33
+ const DEFAULT_SECRET_HEADER = "x-telegram-bot-api-secret-token"
34
+ const DEFAULT_MAX_LEN = 4000
35
+
36
+ export function createChatRoutes(
37
+ getEngine: EngineResolver,
38
+ options: ChatRouteOptions = {}
39
+ ) {
40
+ return {
41
+ /** POST /api/chat — { action: "start" | "send", ... } */
42
+ chat: { POST: (req: Request) => handleChat(getEngine, req, options) },
43
+ /** GET /api/chat/poll?sessionId=..&after=.. */
44
+ poll: { GET: (req: Request) => handlePoll(getEngine, req) },
45
+ /** POST /api/chat/telegram — inbound transport webhook */
46
+ webhook: { POST: (req: Request) => handleWebhook(getEngine, req, options) },
47
+ }
48
+ }
49
+
50
+ async function handleChat(
51
+ getEngine: EngineResolver,
52
+ req: Request,
53
+ options: ChatRouteOptions
54
+ ): Promise<Response> {
55
+ let body: unknown
56
+ try {
57
+ body = await req.json()
58
+ } catch {
59
+ return json({ error: "invalid JSON" }, 400)
60
+ }
61
+
62
+ const action = (body as { action?: string })?.action
63
+ const engine = await getEngine()
64
+ const maxLen = options.maxMessageLength ?? DEFAULT_MAX_LEN
65
+
66
+ try {
67
+ if (action === "start") {
68
+ let visitor = normalizeVisitor((body as { visitor?: unknown }).visitor, maxLen)
69
+ // Server-trusted identity wins over anything the client sent.
70
+ if (options.resolveVisitor) {
71
+ const resolved = await options.resolveVisitor(req)
72
+ if (!resolved) return json({ error: "unauthorized" }, 401)
73
+ visitor = {
74
+ name: resolved.name,
75
+ email: resolved.email,
76
+ topic: visitor?.topic,
77
+ }
78
+ }
79
+ if (!visitor) return json({ error: "name and a valid email are required" }, 400)
80
+ const { sessionId } = await engine.startSession(visitor)
81
+ return json({ sessionId })
82
+ }
83
+
84
+ if (action === "send") {
85
+ const { sessionId, text } = body as { sessionId?: unknown; text?: unknown }
86
+ if (typeof sessionId !== "string" || !sessionId) {
87
+ return json({ error: "sessionId required" }, 400)
88
+ }
89
+ if (typeof text !== "string" || !text.trim()) {
90
+ return json({ error: "text required" }, 400)
91
+ }
92
+ const result = await engine.handleVisitorMessage(sessionId, text.slice(0, maxLen))
93
+ return json({ status: result.session.status, messages: result.newMessages })
94
+ }
95
+
96
+ if (action === "email") {
97
+ const { sessionId } = body as { sessionId?: unknown }
98
+ if (typeof sessionId !== "string" || !sessionId) {
99
+ return json({ error: "sessionId required" }, 400)
100
+ }
101
+ const ok = await engine.emailTranscript(sessionId)
102
+ return json({ ok })
103
+ }
104
+
105
+ if (action === "end") {
106
+ const { sessionId } = body as { sessionId?: unknown }
107
+ if (typeof sessionId !== "string" || !sessionId) {
108
+ return json({ error: "sessionId required" }, 400)
109
+ }
110
+ await engine.abandonSession(sessionId)
111
+ return json({ ok: true })
112
+ }
113
+
114
+ return json({ error: "unknown action" }, 400)
115
+ } catch (err) {
116
+ if (err instanceof ChatSessionNotFound) return json({ error: "session not found" }, 404)
117
+ console.error("[chatkit] chat route error:", err)
118
+ return json({ error: "internal error" }, 500)
119
+ }
120
+ }
121
+
122
+ async function handlePoll(getEngine: EngineResolver, req: Request): Promise<Response> {
123
+ const url = new URL(req.url)
124
+ const sessionId = url.searchParams.get("sessionId")
125
+ const after = Number(url.searchParams.get("after") ?? "0") || 0
126
+ if (!sessionId) return json({ error: "sessionId required" }, 400)
127
+
128
+ try {
129
+ const engine = await getEngine()
130
+ const result = await engine.poll(sessionId, after)
131
+ return json(result)
132
+ } catch (err) {
133
+ if (err instanceof ChatSessionNotFound) return json({ error: "session not found" }, 404)
134
+ console.error("[chatkit] poll route error:", err)
135
+ return json({ error: "internal error" }, 500)
136
+ }
137
+ }
138
+
139
+ async function handleWebhook(
140
+ getEngine: EngineResolver,
141
+ req: Request,
142
+ options: ChatRouteOptions
143
+ ): Promise<Response> {
144
+ if (options.webhookSecret) {
145
+ const header = options.webhookSecretHeader ?? DEFAULT_SECRET_HEADER
146
+ if (req.headers.get(header) !== options.webhookSecret) {
147
+ return json({ ok: false }, 401)
148
+ }
149
+ }
150
+
151
+ let update: unknown
152
+ try {
153
+ update = await req.json()
154
+ } catch {
155
+ // Always 200 so the transport doesn't retry a malformed delivery forever.
156
+ return json({ ok: true })
157
+ }
158
+
159
+ try {
160
+ const engine = await getEngine()
161
+ await engine.handleWebhookUpdate(update)
162
+ } catch (err) {
163
+ console.error("[chatkit] webhook route error:", err)
164
+ }
165
+ return json({ ok: true })
166
+ }
167
+
168
+ function normalizeVisitor(input: unknown, maxLen: number): ChatVisitor | null {
169
+ if (!input || typeof input !== "object") return null
170
+ const { name, email, topic } = input as Record<string, unknown>
171
+ if (typeof name !== "string" || !name.trim()) return null
172
+ if (typeof email !== "string" || !isEmail(email)) return null
173
+ return {
174
+ name: name.trim().slice(0, 200),
175
+ email: email.trim().slice(0, 320),
176
+ topic: typeof topic === "string" && topic.trim() ? topic.trim().slice(0, maxLen) : undefined,
177
+ }
178
+ }
179
+
180
+ function isEmail(s: string): boolean {
181
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s.trim())
182
+ }
183
+
184
+ function json(data: unknown, status = 200): Response {
185
+ return new Response(JSON.stringify(data), {
186
+ status,
187
+ headers: { "Content-Type": "application/json" },
188
+ })
189
+ }