@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/storage.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type { ChatMessage, ChatSession, ChatRole } from "./types"
2
+
3
+ /**
4
+ * Persistence seam. The host app supplies a concrete implementation
5
+ * (e.g. Postgres/Prisma) so chatkit itself stays storage-agnostic.
6
+ *
7
+ * Sessions and the agent↔visitor relay are inherently async and survive
8
+ * across requests/cold-starts, so a real adapter must be durable — the
9
+ * in-memory adapter below is for local dev and the OSS demo only.
10
+ */
11
+ export interface ChatStorage {
12
+ createSession(session: ChatSession): Promise<void>
13
+ getSession(id: string): Promise<ChatSession | null>
14
+ /** Map an inbound transport thread back to its session. */
15
+ getSessionByThread(threadId: string): Promise<ChatSession | null>
16
+ /**
17
+ * Most recent thread id for a visitor email (case-insensitive), or null.
18
+ * Lets the engine reuse one topic per customer instead of spawning a new one
19
+ * each time they start a chat.
20
+ */
21
+ getThreadByEmail(email: string): Promise<string | null>
22
+ updateSession(id: string, patch: Partial<ChatSession>): Promise<void>
23
+
24
+ /**
25
+ * Non-closed sessions whose visitor hasn't been seen since `beforeTs`
26
+ * (using lastSeenAt, falling back to createdAt). Used to find disconnected
27
+ * visitors who may have unseen replies to email.
28
+ */
29
+ getSessionsIdleSince(beforeTs: number): Promise<ChatSession[]>
30
+
31
+ appendMessage(message: ChatMessage): Promise<void>
32
+ getMessages(
33
+ sessionId: string,
34
+ opts?: { after?: number; roles?: ChatRole[] }
35
+ ): Promise<ChatMessage[]>
36
+ }
37
+
38
+ /**
39
+ * Reference adapter — keeps everything in process memory. Fine for `next dev`
40
+ * on a single instance and for trying the OSS demo; do NOT use in production
41
+ * (state is lost on restart and not shared across instances).
42
+ */
43
+ export class InMemoryStorage implements ChatStorage {
44
+ private sessions = new Map<string, ChatSession>()
45
+ private threadIndex = new Map<string, string>()
46
+ private messages = new Map<string, ChatMessage[]>()
47
+
48
+ async createSession(session: ChatSession): Promise<void> {
49
+ this.sessions.set(session.id, { ...session })
50
+ if (session.threadId != null) {
51
+ this.threadIndex.set(session.threadId, session.id)
52
+ }
53
+ if (!this.messages.has(session.id)) this.messages.set(session.id, [])
54
+ }
55
+
56
+ async getSession(id: string): Promise<ChatSession | null> {
57
+ const s = this.sessions.get(id)
58
+ return s ? { ...s } : null
59
+ }
60
+
61
+ async getSessionByThread(threadId: string): Promise<ChatSession | null> {
62
+ const id = this.threadIndex.get(threadId)
63
+ return id ? this.getSession(id) : null
64
+ }
65
+
66
+ async getThreadByEmail(email: string): Promise<string | null> {
67
+ const target = email.trim().toLowerCase()
68
+ let latest: ChatSession | null = null
69
+ for (const s of this.sessions.values()) {
70
+ if (s.threadId && s.visitor.email.trim().toLowerCase() === target) {
71
+ if (!latest || s.createdAt > latest.createdAt) latest = s
72
+ }
73
+ }
74
+ return latest?.threadId ?? null
75
+ }
76
+
77
+ async updateSession(id: string, patch: Partial<ChatSession>): Promise<void> {
78
+ const existing = this.sessions.get(id)
79
+ if (!existing) return
80
+ const next = { ...existing, ...patch, updatedAt: Date.now() }
81
+ this.sessions.set(id, next)
82
+ if (patch.threadId != null) {
83
+ this.threadIndex.set(patch.threadId, id)
84
+ }
85
+ }
86
+
87
+ async getSessionsIdleSince(beforeTs: number): Promise<ChatSession[]> {
88
+ const out: ChatSession[] = []
89
+ for (const s of this.sessions.values()) {
90
+ if (s.status === "closed") continue
91
+ if ((s.lastSeenAt ?? s.createdAt) < beforeTs) out.push({ ...s })
92
+ }
93
+ return out
94
+ }
95
+
96
+ async appendMessage(message: ChatMessage): Promise<void> {
97
+ const list = this.messages.get(message.sessionId) ?? []
98
+ list.push({ ...message })
99
+ this.messages.set(message.sessionId, list)
100
+ }
101
+
102
+ async getMessages(
103
+ sessionId: string,
104
+ opts?: { after?: number; roles?: ChatRole[] }
105
+ ): Promise<ChatMessage[]> {
106
+ let list = this.messages.get(sessionId) ?? []
107
+ if (opts?.after != null) list = list.filter((m) => m.createdAt > opts.after!)
108
+ if (opts?.roles) list = list.filter((m) => opts.roles!.includes(m.role))
109
+ return list.map((m) => ({ ...m }))
110
+ }
111
+ }
@@ -0,0 +1,135 @@
1
+ import type { ChatTransport } from "./transport"
2
+ import type { ParsedAgentReply } from "./types"
3
+
4
+ export interface TelegramConfig {
5
+ /** Bot token from @BotFather (use a DEDICATED bot for chat, not your ops bot). */
6
+ botToken: string
7
+ /**
8
+ * Chat id of the support group. Must be a *forum* supergroup (Topics enabled)
9
+ * and the bot must be an admin with "Manage Topics" permission, otherwise
10
+ * `createForumTopic` fails.
11
+ */
12
+ chatId: string | number
13
+ }
14
+
15
+ /**
16
+ * Telegram transport. v1 of chatkit's "human handoff" surface — designed
17
+ * behind a thin interface so other transports (Slack, Discord, email) can be
18
+ * added later without touching the engine.
19
+ *
20
+ * Each chat session gets its own forum topic; the agent replies inside that
21
+ * topic and the webhook maps `message_thread_id` back to the session.
22
+ */
23
+ export class TelegramTransport implements ChatTransport {
24
+ constructor(private readonly cfg: TelegramConfig) {}
25
+
26
+ private async call<T = unknown>(
27
+ method: string,
28
+ body: Record<string, unknown>
29
+ ): Promise<T> {
30
+ const res = await fetch(
31
+ `https://api.telegram.org/bot${this.cfg.botToken}/${method}`,
32
+ {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({ chat_id: this.cfg.chatId, ...body }),
36
+ }
37
+ )
38
+ const data = (await res.json().catch(() => ({}))) as {
39
+ ok?: boolean
40
+ result?: T
41
+ description?: string
42
+ }
43
+ if (!data.ok) {
44
+ throw new Error(
45
+ `Telegram ${method} failed: ${data.description ?? res.status}`
46
+ )
47
+ }
48
+ return data.result as T
49
+ }
50
+
51
+ /** Create a forum topic and return its `message_thread_id` (as a string). */
52
+ async createTopic(name: string): Promise<string> {
53
+ // Telegram truncates topic names at 128 chars.
54
+ const result = await this.call<{ message_thread_id: number }>(
55
+ "createForumTopic",
56
+ { name: name.slice(0, 128) }
57
+ )
58
+ return String(result.message_thread_id)
59
+ }
60
+
61
+ /** Post a message into a topic. Best-effort: never throws to the caller. */
62
+ async sendMessage(threadId: string, text: string): Promise<void> {
63
+ try {
64
+ await this.call("sendMessage", {
65
+ message_thread_id: Number(threadId),
66
+ text,
67
+ parse_mode: "HTML",
68
+ disable_web_page_preview: true,
69
+ })
70
+ } catch (err) {
71
+ console.error("[chatkit] telegram sendMessage failed:", err)
72
+ }
73
+ }
74
+
75
+ /** Close a topic when a conversation ends (keeps the group tidy). */
76
+ async closeTopic(threadId: string): Promise<void> {
77
+ try {
78
+ await this.call("closeForumTopic", { message_thread_id: Number(threadId) })
79
+ } catch (err) {
80
+ console.error("[chatkit] telegram closeForumTopic failed:", err)
81
+ }
82
+ }
83
+
84
+ /** Reopen a closed topic (so a returning visitor reuses the same thread). */
85
+ async reopenTopic(threadId: string): Promise<void> {
86
+ try {
87
+ await this.call("reopenForumTopic", { message_thread_id: Number(threadId) })
88
+ } catch {
89
+ // Already open, or topic gone — non-fatal.
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Parse an inbound webhook update into an agent reply, or `null` if it isn't
95
+ * a relevant human message. Bots don't receive their own messages, so AI/bot
96
+ * echoes never come back through here. Service messages (topic created, etc.)
97
+ * and non-topic chatter are ignored.
98
+ */
99
+ parseUpdate(update: unknown): ParsedAgentReply | null {
100
+ const msg = (update as { message?: TelegramMessage })?.message
101
+ if (!msg) return null
102
+ if (msg.message_thread_id == null) return null // not inside a topic
103
+ if (msg.from?.is_bot) return null
104
+ if (msg.forum_topic_created || msg.forum_topic_closed) return null
105
+
106
+ const text = (msg.text ?? msg.caption ?? "").trim()
107
+ if (!text) return null
108
+
109
+ const fromName =
110
+ [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ") ||
111
+ msg.from?.username
112
+
113
+ const threadId = String(msg.message_thread_id)
114
+ const lower = text.toLowerCase()
115
+ if (lower === "/close" || lower === "/end") {
116
+ return { threadId, text, fromName, command: "close" }
117
+ }
118
+
119
+ return { threadId, text, fromName }
120
+ }
121
+ }
122
+
123
+ interface TelegramMessage {
124
+ message_thread_id?: number
125
+ text?: string
126
+ caption?: string
127
+ forum_topic_created?: unknown
128
+ forum_topic_closed?: unknown
129
+ from?: {
130
+ is_bot?: boolean
131
+ first_name?: string
132
+ last_name?: string
133
+ username?: string
134
+ }
135
+ }
@@ -0,0 +1,116 @@
1
+ import type { ChatMessage, ChatSession } from "./types"
2
+
3
+ export interface TranscriptOptions {
4
+ /** Brand name used for the AI's label + email copy. */
5
+ brand?: string
6
+ }
7
+
8
+ export interface RenderedTranscript {
9
+ subject: string
10
+ html: string
11
+ text: string
12
+ }
13
+
14
+ /**
15
+ * Render a chat session into an email-ready transcript (subject + HTML + plain
16
+ * text). Pure and host-agnostic — the host wires it to its own mailer.
17
+ */
18
+ export function renderTranscript(
19
+ session: ChatSession,
20
+ messages: ChatMessage[],
21
+ opts: TranscriptOptions = {}
22
+ ): RenderedTranscript {
23
+ const brand = opts.brand ?? "Support"
24
+ const subject = `Your chat with ${brand}`
25
+
26
+ const rows = messages
27
+ .filter((m) => m.text && m.text.trim())
28
+ .map((m) => ({
29
+ role: m.role,
30
+ who:
31
+ m.role === "visitor"
32
+ ? session.visitor.name
33
+ : m.role === "agent"
34
+ ? m.authorName || "Support"
35
+ : m.role === "ai"
36
+ ? brand
37
+ : "",
38
+ text: m.text,
39
+ }))
40
+
41
+ const htmlRows = rows
42
+ .map((r) =>
43
+ r.role === "system"
44
+ ? `<p style="margin:8px 0;color:#888;font-size:13px;font-style:italic">${esc(r.text)}</p>`
45
+ : `<p style="margin:10px 0"><strong>${esc(r.who)}:</strong> ${esc(r.text)}</p>`
46
+ )
47
+ .join("")
48
+
49
+ const html =
50
+ `<div style="font-family:Arial,Helvetica,sans-serif;max-width:560px;margin:0 auto;color:#0f172a">` +
51
+ `<h2 style="margin:0 0 4px">${esc(subject)}</h2>` +
52
+ `<p style="color:#475569;margin:0 0 16px">Here's a copy of your conversation with ${esc(brand)}.</p>` +
53
+ `<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
54
+ htmlRows +
55
+ `<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
56
+ `<p style="color:#94a3b8;font-size:12px">Sent to ${esc(session.visitor.email)}.</p>` +
57
+ `</div>`
58
+
59
+ const text =
60
+ `${subject}\n\n` +
61
+ rows.map((r) => (r.role === "system" ? `(${r.text})` : `${r.who}: ${r.text}`)).join("\n")
62
+
63
+ return { subject, html, text }
64
+ }
65
+
66
+ /**
67
+ * Render a "you have new replies" email for a visitor who disconnected before
68
+ * seeing the agent/AI reply(s). `replyUrl`, if given, links back to the chat.
69
+ */
70
+ export function renderReplyNotification(
71
+ session: ChatSession,
72
+ unseen: ChatMessage[],
73
+ opts: TranscriptOptions & { replyUrl?: string } = {}
74
+ ): RenderedTranscript {
75
+ const brand = opts.brand ?? "Support"
76
+ const subject = `New reply from ${brand}`
77
+
78
+ const rows = unseen
79
+ .filter((m) => m.text && m.text.trim())
80
+ .map((m) => ({
81
+ who: m.role === "agent" ? m.authorName || "Support" : brand,
82
+ text: m.text,
83
+ }))
84
+
85
+ const htmlRows = rows
86
+ .map(
87
+ (r) => `<p style="margin:10px 0"><strong>${esc(r.who)}:</strong> ${esc(r.text)}</p>`
88
+ )
89
+ .join("")
90
+
91
+ const link = opts.replyUrl
92
+ ? `<p style="margin:16px 0"><a href="${esc(opts.replyUrl)}" style="color:#0f172a">Reply to continue the conversation →</a></p>`
93
+ : ""
94
+
95
+ const html =
96
+ `<div style="font-family:Arial,Helvetica,sans-serif;max-width:560px;margin:0 auto;color:#0f172a">` +
97
+ `<h2 style="margin:0 0 4px">${esc(subject)}</h2>` +
98
+ `<p style="color:#475569;margin:0 0 16px">Hi ${esc(session.visitor.name)}, you have a new reply to your chat:</p>` +
99
+ `<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
100
+ htmlRows +
101
+ link +
102
+ `<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
103
+ `<p style="color:#94a3b8;font-size:12px">Sent to ${esc(session.visitor.email)} because you left the chat before this reply arrived.</p>` +
104
+ `</div>`
105
+
106
+ const text =
107
+ `${subject}\n\nHi ${session.visitor.name}, you have a new reply:\n\n` +
108
+ rows.map((r) => `${r.who}: ${r.text}`).join("\n") +
109
+ (opts.replyUrl ? `\n\nReply: ${opts.replyUrl}` : "")
110
+
111
+ return { subject, html, text }
112
+ }
113
+
114
+ function esc(s: string): string {
115
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
116
+ }
@@ -0,0 +1,24 @@
1
+ import type { ParsedAgentReply } from "./types"
2
+
3
+ /**
4
+ * The "human handoff" seam. A transport routes a conversation to wherever your
5
+ * team already works and relays their replies back.
6
+ *
7
+ * v1 ships {@link TelegramTransport}. The interface is deliberately small so a
8
+ * SlackTransport (thread per chat in a channel), DiscordTransport, or
9
+ * email/SMTP transport can be added without touching the engine. Thread ids are
10
+ * opaque strings so any backend's native id fits (Telegram `message_thread_id`,
11
+ * Slack `thread_ts`, etc.).
12
+ */
13
+ export interface ChatTransport {
14
+ /** Create a thread/topic for a new chat and return its opaque id. */
15
+ createTopic(name: string): Promise<string>
16
+ /** Post a message into a thread. Should be best-effort (not throw). */
17
+ sendMessage(threadId: string, text: string): Promise<void>
18
+ /** Close/archive a thread when the conversation ends. */
19
+ closeTopic(threadId: string): Promise<void>
20
+ /** Reopen a previously closed thread (best-effort; no-op where not applicable). */
21
+ reopenTopic(threadId: string): Promise<void>
22
+ /** Parse an inbound webhook payload into an agent reply, or null if irrelevant. */
23
+ parseUpdate(update: unknown): ParsedAgentReply | null
24
+ }
package/src/types.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * chatkit — portable customer-chat module.
3
+ *
4
+ * Core domain types. Nothing in this folder imports from the host app
5
+ * (`@/...`); it depends only on npm packages + web/node built-ins, so the
6
+ * whole `src/chatkit` directory can be lifted out into its own package.
7
+ */
8
+
9
+ /** Who authored a message. */
10
+ export type ChatRole = "visitor" | "ai" | "agent" | "system"
11
+
12
+ /**
13
+ * Lifecycle of a conversation.
14
+ * - `ai` — the AI first-line is handling the visitor.
15
+ * - `live` — a human agent has taken over (AI stays quiet).
16
+ * - `closed` — the conversation has ended.
17
+ */
18
+ export type ChatStatus = "ai" | "live" | "closed"
19
+
20
+ /** Details captured by the pre-chat form before messaging starts. */
21
+ export interface ChatVisitor {
22
+ name: string
23
+ email: string
24
+ /** Optional "what do you need help with?" from the pre-chat form. */
25
+ topic?: string
26
+ }
27
+
28
+ export interface ChatMessage {
29
+ id: string
30
+ sessionId: string
31
+ role: ChatRole
32
+ text: string
33
+ /** Display name of the author (for agent replies — the staff member's name). */
34
+ authorName?: string
35
+ /** Epoch milliseconds. */
36
+ createdAt: number
37
+ }
38
+
39
+ export interface ChatSession {
40
+ /** Opaque public id (uuid) — the client holds this to read/write its session. */
41
+ id: string
42
+ visitor: ChatVisitor
43
+ /**
44
+ * Transport thread id, once the thread/topic is created. Opaque string so any
45
+ * backend fits (Telegram `message_thread_id`, Slack `thread_ts`, …).
46
+ */
47
+ threadId: string | null
48
+ status: ChatStatus
49
+ createdAt: number
50
+ updatedAt: number
51
+ /** Last time the visitor's widget polled/heartbeat — used to detect disconnect. */
52
+ lastSeenAt?: number
53
+ /** Timestamp up to which unseen replies have been emailed (avoids re-sending). */
54
+ notifiedAt?: number
55
+ }
56
+
57
+ /** A human agent's reply parsed from an inbound transport webhook update. */
58
+ export interface ParsedAgentReply {
59
+ threadId: string
60
+ text: string
61
+ /** Display name of the agent who replied, if available. */
62
+ fromName?: string
63
+ /** True when the message is a control command (e.g. `/close`). */
64
+ command?: "close"
65
+ }
66
+
67
+ /** Result of an AI first-line turn. */
68
+ export interface AIReply {
69
+ /** The text to show the visitor. */
70
+ text: string
71
+ /** True when the AI wants a human to take over. */
72
+ escalate: boolean
73
+ /** Short internal note on why it escalated (shown to agents, not the visitor). */
74
+ reason?: string
75
+ }
76
+
77
+ /**
78
+ * Optional first-line layer. Implement this to plug in a different LLM —
79
+ * cloud ({@link AnthropicProvider}) or local/self-hosted ({@link LocalProvider}).
80
+ */
81
+ export interface AIProvider {
82
+ reply(history: ChatMessage[], visitor: ChatVisitor): Promise<AIReply>
83
+ }