@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 freewaretools
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # @freewaretools/outercom
2
+
3
+ A small, self-hosted **live-chat widget that hands off to the messenger your team already uses**. Telegram in v1 (a forum **topic per customer**), with an optional **Claude or local-LLM** first-line that answers common questions and escalates to a human when it can't.
4
+
5
+ No SaaS, no per-seat fees — you own the data and the code.
6
+
7
+ > **Status:** in production. Portable by design — the package has **no host-app imports** and **no brand baked in** (the brand name, AI label, knowledge base, and theming are all injected by the host); persistence, transport, and AI are pluggable interfaces. Open-source under the MIT license.
8
+
9
+ ## Install
10
+
11
+ From npm:
12
+
13
+ ```bash
14
+ npm i @freewaretools/outercom
15
+ ```
16
+
17
+ Or straight from GitHub (git-URL install — `dist/` is committed, so no build step):
18
+
19
+ ```bash
20
+ npm i github:freewaretools/outercom#v0.4.0
21
+ ```
22
+
23
+ Peer deps (install whichever you use): `react` (for the widget), `@anthropic-ai/sdk` (only if you use the Claude provider — it's lazy-loaded, so local-LLM / human-only setups don't need it).
24
+
25
+ Entry points: `@freewaretools/outercom` (core), `@freewaretools/outercom/react` (widget), `@freewaretools/outercom/next` (App Router route factories).
26
+
27
+ ## How it works
28
+
29
+ ```
30
+ Visitor ──► ChatWidget ──► /api/chat ──► ChatEngine ──┬─► AIProvider (Claude / local, first-line)
31
+ ▲ └─► ChatTransport (Telegram topic)
32
+ │ │
33
+ └──────────── /api/chat/poll ◄── ChatStorage ◄── /api/chat/telegram (agent reply webhook)
34
+ ```
35
+
36
+ 1. The widget collects **name + email** (+ optional question) before any messaging.
37
+ 2. On start, the engine reuses (or opens) a **forum topic per customer** named `Name · email` and posts the details.
38
+ 3. The AI answers first-line from your knowledge base; if it can't, it **escalates** (pings the topic, tells the visitor a human is coming).
39
+ 4. An agent replies **inside the topic** → the webhook maps `message_thread_id` → session → the visitor sees it on the next poll. The AI goes quiet once a human joins.
40
+
41
+ ## Architecture (the seams)
42
+
43
+ | Piece | Interface | Ships | Swap for… |
44
+ |---|---|---|---|
45
+ | Persistence | `ChatStorage` | `InMemoryStorage` (dev only) | a Postgres/Prisma adapter (the host provides one) |
46
+ | Handoff | `ChatTransport` | `TelegramTransport` | `SlackTransport`, `DiscordTransport`, email… |
47
+ | First-line AI | `AIProvider` | `AnthropicProvider` (Claude Haiku 4.5) · `LocalProvider` (any OpenAI-compatible server) | any LLM, or omit for human-only |
48
+
49
+ Thread ids are **opaque strings**, so a transport's native id fits as-is (Telegram `message_thread_id`, Slack `thread_ts`, …).
50
+
51
+ ## Quick start (Next.js App Router)
52
+
53
+ ```ts
54
+ // build the engine once (host app supplies storage + secrets)
55
+ import { ChatEngine, TelegramTransport, AnthropicProvider, renderTranscript } from "@freewaretools/outercom"
56
+ import { createChatRoutes } from "@freewaretools/outercom/next"
57
+ import { PrismaChatStorage } from "@/lib/chat/prisma-storage"
58
+ import { sendEmail } from "@/lib/email"
59
+
60
+ const engine = new ChatEngine({
61
+ storage: new PrismaChatStorage(),
62
+ transport: new TelegramTransport({ botToken: TOKEN, chatId: GROUP_ID }),
63
+ ai: new AnthropicProvider({ apiKey: KEY, knowledgeBase: KB, brand: "Acme" }),
64
+ topicName: (v) => `${v.name} · ${v.email}`,
65
+ sendTranscript: async (session, messages) => {
66
+ const { subject, html, text } = renderTranscript(session, messages, { brand: "Acme" })
67
+ await sendEmail({ to: session.visitor.email, subject, html, text })
68
+ },
69
+ })
70
+
71
+ export const routes = createChatRoutes(() => engine, { webhookSecret: SECRET })
72
+ ```
73
+
74
+ ```ts
75
+ // src/app/api/chat/route.ts
76
+ import { routes } from "@/lib/chat/config"
77
+ export const runtime = "nodejs"
78
+ export const POST = routes.chat.POST
79
+ ```
80
+
81
+ …and likewise `app/api/chat/poll/route.ts` → `routes.poll.GET`, `app/api/chat/telegram/route.ts` → `routes.webhook.POST`.
82
+
83
+ ```tsx
84
+ // mount the widget — self-gates via a runtime config route so on/off isn't baked into a build
85
+ import { ChatWidget } from "@freewaretools/outercom/react"
86
+ <ChatWidget configUrl="/api/chat/config" title="Live Chat" accentColor="#0f172a" />
87
+ ```
88
+
89
+ ## Features
90
+
91
+ - **Topic-per-customer** — reused (and reopened) by email, so one thread per person, not a pile of duplicates.
92
+ - **AI first-line** — Claude (`AnthropicProvider`, default Haiku 4.5) or any OpenAI-compatible local model (`LocalProvider`: Ollama / LM Studio / llama.cpp / vLLM). Same persona + `{reply, escalate, reason}` contract, defensive JSON parsing with plain-text fallback.
93
+ - **Agent names** — the replying staff member's name is shown on their bubbles.
94
+ - **Transcript email** — auto-sent on close, plus an "Email me a copy" button (`renderTranscript` + a host `sendTranscript` hook).
95
+ - **Notification sounds** — soft Web-Audio "boop" on incoming replies, with mute toggle + unread dot on the launcher.
96
+ - **Reset/abandon** — visitor reset posts a notice into the topic and closes it, so agents don't reply into a dead chat.
97
+
98
+ ## AI: cloud or local
99
+
100
+ ```ts
101
+ // Cloud — Claude Haiku 4.5 (cheapest tier)
102
+ new AnthropicProvider({ apiKey, knowledgeBase, brand: "Acme" })
103
+
104
+ // Local — anything speaking the OpenAI Chat Completions API
105
+ new LocalProvider({
106
+ baseUrl: "http://192.168.x.x:11434/v1", // Ollama; LM Studio :1234, llama.cpp :8080, vLLM :8000
107
+ model: "gemma3:12b",
108
+ knowledgeBase,
109
+ brand: "Acme",
110
+ })
111
+ ```
112
+
113
+ A local model keeps all chat data on your own infrastructure with no per-token cost. From inside Docker, point `baseUrl` at the LAN IP / `host.docker.internal`, not `localhost`, and bind the model server to `0.0.0.0`.
114
+
115
+ ## Telegram setup
116
+
117
+ 1. **@BotFather → `/newbot`** → copy the token (a *dedicated* bot — a bot can only have one webhook URL).
118
+ 2. Create a **group**, make it a **forum** (Group → Edit → *Topics* on).
119
+ 3. Add the bot as **admin** — and crucially enable the **Manage Topics** admin right (being an admin alone isn't enough to create topics).
120
+ 4. Get the group's chat id (negative `-100…`); confirm with `getChat` → `is_forum: true`.
121
+ 5. Register the webhook with a secret:
122
+ ```
123
+ curl "https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://yoursite/api/chat/telegram&secret_token=<SECRET>"
124
+ ```
125
+
126
+ ## Extending: Slack (or anything)
127
+
128
+ Implement `ChatTransport` and pass it to the engine — no engine changes:
129
+
130
+ ```ts
131
+ class SlackTransport implements ChatTransport {
132
+ createTopic(name) { /* post a parent message → return its thread_ts */ }
133
+ sendMessage(threadId, text) { /* chat.postMessage thread_ts=threadId */ }
134
+ closeTopic(threadId) { /* archive / post a "closed" note */ }
135
+ reopenTopic(threadId) { /* optional */ }
136
+ parseUpdate(evt) { /* Events API message in a thread → { threadId, text } */ }
137
+ }
138
+ ```
139
+
140
+ ## Notes
141
+
142
+ - **Storage must be durable in prod** — agent replies arrive asynchronously; sessions must survive cold starts / multiple instances. Use a Postgres adapter, not `InMemoryStorage`.
143
+ - **Delivery to the visitor is poll-based** (robust + serverless-friendly). SSE is a possible upgrade.
144
+ - **Rate limiting / spam protection** on `/api/chat` is the host's responsibility.
145
+ - The session id is an unguessable UUID held by the client; it grants access to that session only. For authenticated apps, derive the visitor server-side rather than trusting the client.
146
+
147
+ ## Releasing
148
+
149
+ `dist/` is committed so git-URL installs need no build step. To cut a release: `npm run build`, commit `dist/`, tag (`git tag v0.x.0 && git push --tags`), then `npm publish`. Consumers use `npm i @freewaretools/outercom` or pin `github:freewaretools/outercom#v0.x.0`.
150
+
151
+ ---
152
+
153
+ MIT — see [LICENSE](./LICENSE).
package/dist/ai.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { AIProvider, ChatMessage, ChatVisitor } from "./types";
2
+ export interface AnthropicProviderConfig {
3
+ apiKey: string;
4
+ /**
5
+ * Model id. Defaults to Claude Haiku 4.5 — cheapest tier ($1/$5 per 1M
6
+ * tokens), ample for grounded first-line support. Bump to
7
+ * `claude-sonnet-4-6` if answers need more nuance.
8
+ */
9
+ model?: string;
10
+ /** Product/FAQ content the AI must ground its answers in. */
11
+ knowledgeBase: string;
12
+ /** Company/brand name used in the persona. */
13
+ brand?: string;
14
+ /** Extra persona/policy text appended to the system prompt. */
15
+ systemPromptExtra?: string;
16
+ maxTokens?: number;
17
+ }
18
+ /**
19
+ * Claude-backed first-line responder. Asks for a small JSON envelope and parses
20
+ * it defensively (falls back to plain text). No `thinking` / `effort` — both
21
+ * error on Haiku 4.5 and a support first-line doesn't need them.
22
+ */
23
+ export declare class AnthropicProvider implements AIProvider {
24
+ private readonly cfg;
25
+ private clientPromise;
26
+ private readonly model;
27
+ private readonly maxTokens;
28
+ constructor(cfg: AnthropicProviderConfig);
29
+ private client;
30
+ reply(history: ChatMessage[], visitor: ChatVisitor): Promise<import("./types").AIReply>;
31
+ }
package/dist/ai.js ADDED
@@ -0,0 +1,42 @@
1
+ import { buildSupportSystemPrompt, parseAIReply, toChatTurns } from "./prompt";
2
+ const DEFAULT_MODEL = "claude-haiku-4-5";
3
+ /**
4
+ * Claude-backed first-line responder. Asks for a small JSON envelope and parses
5
+ * it defensively (falls back to plain text). No `thinking` / `effort` — both
6
+ * error on Haiku 4.5 and a support first-line doesn't need them.
7
+ */
8
+ export class AnthropicProvider {
9
+ constructor(cfg) {
10
+ this.cfg = cfg;
11
+ this.clientPromise = null;
12
+ this.model = cfg.model ?? DEFAULT_MODEL;
13
+ this.maxTokens = cfg.maxTokens ?? 1024;
14
+ }
15
+ // Lazy-load the SDK so it's only required when Claude is actually used —
16
+ // local-LLM / human-only consumers don't need @anthropic-ai/sdk installed.
17
+ client() {
18
+ if (!this.clientPromise) {
19
+ this.clientPromise = import("@anthropic-ai/sdk").then((m) => new m.default({ apiKey: this.cfg.apiKey }));
20
+ }
21
+ return this.clientPromise;
22
+ }
23
+ async reply(history, visitor) {
24
+ const turns = toChatTurns(history);
25
+ if (turns.length === 0 || turns[0].role !== "user") {
26
+ return { text: "Thanks for reaching out — how can I help?", escalate: false };
27
+ }
28
+ const client = await this.client();
29
+ const res = await client.messages.create({
30
+ model: this.model,
31
+ max_tokens: this.maxTokens,
32
+ system: buildSupportSystemPrompt(this.cfg, visitor),
33
+ messages: turns,
34
+ });
35
+ const raw = res.content
36
+ .filter((b) => b.type === "text")
37
+ .map((b) => b.text)
38
+ .join("")
39
+ .trim();
40
+ return parseAIReply(raw);
41
+ }
42
+ }
@@ -0,0 +1,96 @@
1
+ import type { ChatStorage } from "./storage";
2
+ import type { ChatTransport } from "./transport";
3
+ import type { AIProvider, ChatMessage, ChatSession, ChatVisitor } from "./types";
4
+ export interface ChatEngineOptions {
5
+ storage: ChatStorage;
6
+ transport: ChatTransport;
7
+ /** Optional first-line AI. Omit (or pass `aiEnabled: false`) for human-only chat. */
8
+ ai?: AIProvider;
9
+ aiEnabled?: boolean;
10
+ /** Builds the forum-topic title for a new chat. Default: "Name · email". */
11
+ topicName?: (visitor: ChatVisitor) => string;
12
+ /** Message shown to the visitor when a chat is escalated to a human. */
13
+ escalationMessage?: string;
14
+ /**
15
+ * Optional hook to email/send a transcript. Called when a chat is closed and
16
+ * when the visitor requests a copy. Host-provided (e.g. render + Resend) so
17
+ * chatkit stays mailer-agnostic.
18
+ */
19
+ sendTranscript?: (session: ChatSession, messages: ChatMessage[]) => Promise<void>;
20
+ /**
21
+ * Optional hook to email a visitor replies they haven't seen (because they
22
+ * disconnected). Called by {@link flushUnseen}. Host-provided.
23
+ */
24
+ sendReplyNotification?: (session: ChatSession, unseen: ChatMessage[]) => Promise<void>;
25
+ }
26
+ /**
27
+ * Orchestrates a conversation across the storage, Telegram transport, and the
28
+ * optional AI first-line. Transport-agnostic at heart — `transport` is the only
29
+ * Telegram-specific dependency and sits behind a small surface.
30
+ */
31
+ export declare class ChatEngine {
32
+ private readonly storage;
33
+ private readonly transport;
34
+ private readonly ai?;
35
+ private readonly aiEnabled;
36
+ private readonly topicName;
37
+ private readonly escalationMessage;
38
+ private readonly sendTranscript?;
39
+ private readonly sendReplyNotification?;
40
+ constructor(opts: ChatEngineOptions);
41
+ /** Email the visitor a transcript on demand. Returns false if no mailer is wired. */
42
+ emailTranscript(sessionId: string): Promise<boolean>;
43
+ /**
44
+ * Start a new chat: create the session, open a Telegram topic, and post the
45
+ * captured visitor details. The pre-chat form has already collected name +
46
+ * email (+ optional question) so a topic-per-chat is created up front.
47
+ */
48
+ startSession(visitor: ChatVisitor): Promise<{
49
+ sessionId: string;
50
+ }>;
51
+ /**
52
+ * Handle a visitor message: persist + mirror to Telegram, then (if the AI is
53
+ * handling) generate a reply. Returns any new client-facing messages produced
54
+ * synchronously (the AI reply / escalation note) so the widget can render
55
+ * them immediately; async agent replies arrive via {@link poll}.
56
+ */
57
+ handleVisitorMessage(sessionId: string, text: string): Promise<{
58
+ session: ChatSession;
59
+ newMessages: ChatMessage[];
60
+ }>;
61
+ /**
62
+ * Handle an inbound Telegram webhook update — a human agent replying in a
63
+ * topic. Flips the session to `live` so the AI stays quiet, or closes it on
64
+ * `/close`. Returns true if the update mapped to a known session.
65
+ */
66
+ handleWebhookUpdate(update: unknown): Promise<boolean>;
67
+ /**
68
+ * Poll for messages the visitor hasn't seen yet, plus the live status. Also
69
+ * records the visitor as "seen" now (a present visitor is polling/heartbeating)
70
+ * — the disconnect detector keys off this.
71
+ */
72
+ poll(sessionId: string, after?: number): Promise<{
73
+ status: ChatSession["status"];
74
+ messages: ChatMessage[];
75
+ }>;
76
+ /**
77
+ * Email visitors who disconnected (no poll for >= thresholdMs) the agent/AI
78
+ * replies they never saw. Idempotent via `notifiedAt`. Call from a cron.
79
+ * Returns how many sessions were emailed.
80
+ */
81
+ flushUnseen(thresholdMs: number): Promise<number>;
82
+ /**
83
+ * The visitor ended/reset the chat from their side. Post a notice into the
84
+ * topic so agents know replies won't reach them, then close it. No transcript
85
+ * email (the visitor chose to start over, not to wrap up).
86
+ */
87
+ abandonSession(sessionId: string): Promise<void>;
88
+ closeSession(sessionId: string): Promise<void>;
89
+ private requireSession;
90
+ private append;
91
+ private mirror;
92
+ }
93
+ export declare class ChatSessionNotFound extends Error {
94
+ readonly sessionId: string;
95
+ constructor(sessionId: string);
96
+ }
package/dist/engine.js ADDED
@@ -0,0 +1,282 @@
1
+ const DEFAULT_ESCALATION = "I've passed this to our team — someone will jump in here shortly. Feel free to add any extra detail in the meantime.";
2
+ /** Messages the client should render (everything except the visitor's own echoes). */
3
+ const CLIENT_ROLES = ["ai", "agent", "system"];
4
+ /**
5
+ * Orchestrates a conversation across the storage, Telegram transport, and the
6
+ * optional AI first-line. Transport-agnostic at heart — `transport` is the only
7
+ * Telegram-specific dependency and sits behind a small surface.
8
+ */
9
+ export class ChatEngine {
10
+ constructor(opts) {
11
+ this.storage = opts.storage;
12
+ this.transport = opts.transport;
13
+ this.ai = opts.ai;
14
+ this.aiEnabled = (opts.aiEnabled ?? true) && !!opts.ai;
15
+ this.topicName =
16
+ opts.topicName ?? ((v) => `${v.name} · ${v.email}`);
17
+ this.escalationMessage = opts.escalationMessage ?? DEFAULT_ESCALATION;
18
+ this.sendTranscript = opts.sendTranscript;
19
+ this.sendReplyNotification = opts.sendReplyNotification;
20
+ }
21
+ /** Email the visitor a transcript on demand. Returns false if no mailer is wired. */
22
+ async emailTranscript(sessionId) {
23
+ if (!this.sendTranscript)
24
+ return false;
25
+ const session = await this.storage.getSession(sessionId);
26
+ if (!session)
27
+ return false;
28
+ try {
29
+ const messages = await this.storage.getMessages(sessionId);
30
+ await this.sendTranscript(session, messages);
31
+ return true;
32
+ }
33
+ catch (err) {
34
+ console.error("[chatkit] sendTranscript failed:", err);
35
+ return false;
36
+ }
37
+ }
38
+ /**
39
+ * Start a new chat: create the session, open a Telegram topic, and post the
40
+ * captured visitor details. The pre-chat form has already collected name +
41
+ * email (+ optional question) so a topic-per-chat is created up front.
42
+ */
43
+ async startSession(visitor) {
44
+ const id = crypto.randomUUID();
45
+ const now = Date.now();
46
+ // Reuse one topic per customer email; reopen it if a prior chat closed it.
47
+ let threadId = await this.storage.getThreadByEmail(visitor.email);
48
+ const reused = threadId !== null;
49
+ if (threadId !== null) {
50
+ await this.transport.reopenTopic(threadId);
51
+ }
52
+ else {
53
+ try {
54
+ threadId = await this.transport.createTopic(this.topicName(visitor));
55
+ }
56
+ catch (err) {
57
+ // If topic creation fails (misconfigured group/perms), the chat still
58
+ // works AI-only; agent relay just won't be available until fixed.
59
+ console.error("[chatkit] could not create transport thread:", err);
60
+ threadId = null;
61
+ }
62
+ }
63
+ const session = {
64
+ id,
65
+ visitor,
66
+ threadId,
67
+ status: this.aiEnabled ? "ai" : "live",
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ };
71
+ await this.storage.createSession(session);
72
+ // Post the Telegram intro + handle the opening question in the BACKGROUND so
73
+ // "Start chat" returns immediately. Awaiting these blocked the widget on two
74
+ // Telegram round-trips plus the full AI generation. The widget shows the
75
+ // opening message optimistically and the AI reply arrives via poll().
76
+ void (async () => {
77
+ try {
78
+ if (threadId != null) {
79
+ const header = reused
80
+ ? "🔁 <b>Returning visitor — new chat</b>"
81
+ : "🆕 <b>New chat</b>";
82
+ const intro = `${header}\n` +
83
+ `From: ${esc(visitor.name)} (${esc(visitor.email)})\n` +
84
+ (visitor.topic ? `Topic: ${esc(visitor.topic)}\n` : "") +
85
+ (this.aiEnabled
86
+ ? `\nThe AI assistant is handling this. Reply in this topic to take over.`
87
+ : `\nReply in this topic to respond.`);
88
+ await this.transport.sendMessage(threadId, intro);
89
+ }
90
+ // The pre-chat question becomes the visitor's opening message.
91
+ if (visitor.topic) {
92
+ await this.handleVisitorMessage(id, visitor.topic);
93
+ }
94
+ }
95
+ catch (err) {
96
+ console.error("[chatkit] post-start tasks failed:", err);
97
+ }
98
+ })();
99
+ return { sessionId: id };
100
+ }
101
+ /**
102
+ * Handle a visitor message: persist + mirror to Telegram, then (if the AI is
103
+ * handling) generate a reply. Returns any new client-facing messages produced
104
+ * synchronously (the AI reply / escalation note) so the widget can render
105
+ * them immediately; async agent replies arrive via {@link poll}.
106
+ */
107
+ async handleVisitorMessage(sessionId, text) {
108
+ const session = await this.requireSession(sessionId);
109
+ const clean = text.trim();
110
+ if (!clean)
111
+ return { session, newMessages: [] };
112
+ await this.append(session, "visitor", clean);
113
+ await this.mirror(session, `💬 <b>${esc(session.visitor.name)}:</b> ${esc(clean)}`);
114
+ const produced = [];
115
+ if (session.status === "ai" && this.aiEnabled && this.ai) {
116
+ try {
117
+ const history = await this.storage.getMessages(sessionId);
118
+ const ai = await this.ai.reply(history, session.visitor);
119
+ if (ai.escalate) {
120
+ await this.mirror(session, `🙋 <b>AI requested a human</b>${ai.reason ? ` — ${esc(ai.reason)}` : ""}`);
121
+ const note = await this.append(session, "system", this.escalationMessage);
122
+ produced.push(note);
123
+ await this.storage.updateSession(sessionId, { status: "live" });
124
+ }
125
+ else {
126
+ const reply = await this.append(session, "ai", ai.text);
127
+ produced.push(reply);
128
+ await this.mirror(session, `🤖 <b>AI:</b> ${esc(ai.text)}`);
129
+ }
130
+ }
131
+ catch (err) {
132
+ console.error("[chatkit] AI reply failed, escalating:", err);
133
+ const note = await this.append(session, "system", this.escalationMessage);
134
+ produced.push(note);
135
+ await this.storage.updateSession(sessionId, { status: "live" });
136
+ await this.mirror(session, `⚠️ <b>AI error — needs a human.</b>`);
137
+ }
138
+ }
139
+ return { session: await this.requireSession(sessionId), newMessages: produced };
140
+ }
141
+ /**
142
+ * Handle an inbound Telegram webhook update — a human agent replying in a
143
+ * topic. Flips the session to `live` so the AI stays quiet, or closes it on
144
+ * `/close`. Returns true if the update mapped to a known session.
145
+ */
146
+ async handleWebhookUpdate(update) {
147
+ const parsed = this.transport.parseUpdate(update);
148
+ if (!parsed)
149
+ return false;
150
+ const session = await this.storage.getSessionByThread(parsed.threadId);
151
+ if (!session)
152
+ return false;
153
+ if (parsed.command === "close") {
154
+ await this.closeSession(session.id);
155
+ return true;
156
+ }
157
+ await this.append(session, "agent", parsed.text, parsed.fromName);
158
+ if (session.status !== "live") {
159
+ await this.storage.updateSession(session.id, { status: "live" });
160
+ }
161
+ return true;
162
+ }
163
+ /**
164
+ * Poll for messages the visitor hasn't seen yet, plus the live status. Also
165
+ * records the visitor as "seen" now (a present visitor is polling/heartbeating)
166
+ * — the disconnect detector keys off this.
167
+ */
168
+ async poll(sessionId, after = 0) {
169
+ const session = await this.requireSession(sessionId);
170
+ const messages = await this.storage.getMessages(sessionId, {
171
+ after,
172
+ roles: CLIENT_ROLES,
173
+ });
174
+ if (session.status !== "closed") {
175
+ await this.storage.updateSession(sessionId, { lastSeenAt: Date.now() });
176
+ }
177
+ return { status: session.status, messages };
178
+ }
179
+ /**
180
+ * Email visitors who disconnected (no poll for >= thresholdMs) the agent/AI
181
+ * replies they never saw. Idempotent via `notifiedAt`. Call from a cron.
182
+ * Returns how many sessions were emailed.
183
+ */
184
+ async flushUnseen(thresholdMs) {
185
+ if (!this.sendReplyNotification || thresholdMs <= 0)
186
+ return 0;
187
+ const now = Date.now();
188
+ const idle = await this.storage.getSessionsIdleSince(now - thresholdMs);
189
+ let sent = 0;
190
+ for (const session of idle) {
191
+ const since = Math.max(session.lastSeenAt ?? 0, session.notifiedAt ?? 0);
192
+ const unseen = await this.storage.getMessages(session.id, {
193
+ after: since,
194
+ roles: ["ai", "agent"],
195
+ });
196
+ if (unseen.length === 0)
197
+ continue;
198
+ try {
199
+ await this.sendReplyNotification(session, unseen);
200
+ await this.storage.updateSession(session.id, { notifiedAt: now });
201
+ sent++;
202
+ }
203
+ catch (err) {
204
+ console.error("[chatkit] reply notification failed:", err);
205
+ }
206
+ }
207
+ return sent;
208
+ }
209
+ /**
210
+ * The visitor ended/reset the chat from their side. Post a notice into the
211
+ * topic so agents know replies won't reach them, then close it. No transcript
212
+ * email (the visitor chose to start over, not to wrap up).
213
+ */
214
+ async abandonSession(sessionId) {
215
+ const session = await this.storage.getSession(sessionId);
216
+ if (!session || session.status === "closed")
217
+ return;
218
+ await this.mirror(session, "🚪 <b>The visitor left / reset the chat.</b> Replies here won't reach them.");
219
+ await this.storage.updateSession(sessionId, { status: "closed" });
220
+ if (session.threadId != null) {
221
+ await this.transport.closeTopic(session.threadId);
222
+ }
223
+ }
224
+ async closeSession(sessionId) {
225
+ const session = await this.storage.getSession(sessionId);
226
+ if (!session)
227
+ return;
228
+ const alreadyClosed = session.status === "closed";
229
+ if (!alreadyClosed) {
230
+ await this.append(session, "system", "This chat has been closed. Thanks for getting in touch!");
231
+ }
232
+ await this.storage.updateSession(sessionId, { status: "closed" });
233
+ // Email the transcript once, on the transition to closed.
234
+ if (!alreadyClosed && this.sendTranscript) {
235
+ try {
236
+ const messages = await this.storage.getMessages(sessionId);
237
+ await this.sendTranscript(session, messages);
238
+ }
239
+ catch (err) {
240
+ console.error("[chatkit] transcript on close failed:", err);
241
+ }
242
+ }
243
+ if (session.threadId != null) {
244
+ await this.transport.closeTopic(session.threadId);
245
+ }
246
+ }
247
+ // --- internals -----------------------------------------------------------
248
+ async requireSession(id) {
249
+ const s = await this.storage.getSession(id);
250
+ if (!s)
251
+ throw new ChatSessionNotFound(id);
252
+ return s;
253
+ }
254
+ async append(session, role, text, authorName) {
255
+ const message = {
256
+ id: crypto.randomUUID(),
257
+ sessionId: session.id,
258
+ role,
259
+ text,
260
+ createdAt: Date.now(),
261
+ ...(authorName ? { authorName } : {}),
262
+ };
263
+ await this.storage.appendMessage(message);
264
+ return message;
265
+ }
266
+ async mirror(session, html) {
267
+ if (session.threadId != null) {
268
+ await this.transport.sendMessage(session.threadId, html);
269
+ }
270
+ }
271
+ }
272
+ export class ChatSessionNotFound extends Error {
273
+ constructor(sessionId) {
274
+ super(`chat session not found: ${sessionId}`);
275
+ this.sessionId = sessionId;
276
+ this.name = "ChatSessionNotFound";
277
+ }
278
+ }
279
+ /** Escape text for Telegram HTML parse mode. */
280
+ function esc(s) {
281
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
282
+ }
@@ -0,0 +1,23 @@
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
+ export * from "./types";
17
+ export * from "./storage";
18
+ export * from "./transport";
19
+ export * from "./telegram";
20
+ export * from "./ai";
21
+ export * from "./local";
22
+ export * from "./transcript";
23
+ export * from "./engine";
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
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
+ export * from "./types";
17
+ export * from "./storage";
18
+ export * from "./transport";
19
+ export * from "./telegram";
20
+ export * from "./ai";
21
+ export * from "./local";
22
+ export * from "./transcript";
23
+ export * from "./engine";