@paramms/chat-widget 0.1.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.
@@ -0,0 +1,114 @@
1
+ import type { ActionId, ProfileId, TenantId } from './ids.js'
2
+
3
+ // ── Actions: the product primitive ────────────────────────────────────────────
4
+ // An action is data an admin authors in the dashboard; the runtime stays generic
5
+ // and only knows how to execute a small, fixed set of EFFECTS. Adding "make
6
+ // offer" or "schedule meeting" is a config row, not a code deploy.
7
+
8
+ export type ActionAudience = 'guest' | 'agent' | 'both'
9
+ export type ActionSurface = 'toolbar' | 'inline' | 'quick_reply'
10
+
11
+ export interface ActionInputField {
12
+ name: string
13
+ label: string
14
+ type: 'text' | 'number' | 'date' | 'select'
15
+ required?: boolean
16
+ options?: string[] // for type: 'select'
17
+ }
18
+
19
+ // A terminal effect produces a result and ends the action. Actions are
20
+ // single-shot: structured multi-step lives in the conversation state machine,
21
+ // and conversational multi-step is the `bot` effect — not an action workflow.
22
+ export type TerminalEffect =
23
+ | { type: 'webhook'; url: string } // signed POST to tenant system
24
+ | { type: 'state_transition'; target: 'conversation' | 'subject'; toState: string }
25
+ | { type: 'bot' } // route to the AI resolver
26
+ | { type: 'builtin'; name: string } // e.g. 'handoff'
27
+
28
+ // The ONLY composition allowed is "collect a form, then run one terminal
29
+ // effect" — exactly one level deep. This covers input-gathering (e.g. an offer
30
+ // amount) without becoming a workflow engine.
31
+ export type ActionEffect =
32
+ | TerminalEffect
33
+ | { type: 'form'; fields: ActionInputField[]; then: TerminalEffect }
34
+
35
+ export type ActionResult =
36
+ | { kind: 'system_message'; template?: string } // post a system line into the chat
37
+ | { kind: 'card' } // render the effect's response as a card
38
+ | { kind: 'state_badge' } // reflect a state change
39
+ | { kind: 'none' }
40
+
41
+ export interface ActionDef {
42
+ id: ActionId
43
+ label: string
44
+ icon?: string
45
+ confirm?: boolean
46
+ audience: ActionAudience
47
+ surface: ActionSurface
48
+ availableInStates?: string[] // conversation/subject states; omit = always available
49
+ effect: ActionEffect
50
+ result: ActionResult
51
+ }
52
+
53
+ // Client-safe projection of an action: enough for the widget to render it and
54
+ // collect inputs, but NONE of the effect internals (webhook URLs, transition
55
+ // targets) — those stay server-side and execute on `invoke`. The client filters
56
+ // by `availableInStates` locally against the current conversation state, so a
57
+ // state change needs no manifest round-trip; the server re-validates on invoke.
58
+ export interface ManifestAction {
59
+ id: ActionId
60
+ label: string
61
+ icon?: string
62
+ confirm?: boolean
63
+ audience: ActionAudience
64
+ surface: ActionSurface
65
+ availableInStates?: string[]
66
+ input?: ActionInputField[] // present when the action collects input (form effect)
67
+ }
68
+
69
+ /** Project an internal action to its client-safe manifest form. */
70
+ export function toManifestAction(a: ActionDef): ManifestAction {
71
+ const input = a.effect.type === 'form' ? a.effect.fields : undefined
72
+ return {
73
+ id: a.id, label: a.label, audience: a.audience, surface: a.surface,
74
+ ...(a.icon ? { icon: a.icon } : {}),
75
+ ...(a.confirm ? { confirm: a.confirm } : {}),
76
+ ...(a.availableInStates ? { availableInStates: a.availableInStates } : {}),
77
+ ...(input ? { input } : {}),
78
+ }
79
+ }
80
+
81
+ // ── Behavior profile (what "domain" becomes) ──────────────────────────────────
82
+ // A reusable, admin-composed bundle of actions + defaults + state machine. Not a
83
+ // built-in taxonomy — the 7 old templates become starter presets of this shape.
84
+ // `version` lets an in-flight invocation validate against a consistent snapshot.
85
+
86
+ /** Operating hours slot: 0=Sun … 6=Sat, times in "HH:MM" 24h local. */
87
+ export interface OperatingHoursSlot { day: 0|1|2|3|4|5|6; open: string; close: string }
88
+
89
+ export interface BehaviorProfile {
90
+ id: ProfileId
91
+ tenantId: TenantId
92
+ name: string
93
+ actions: ActionDef[]
94
+ defaults: {
95
+ greeting?: string
96
+ theme?: { accent: string }
97
+ e2e?: boolean
98
+ persona?: string
99
+ /** Paid-tier flag: when true, hides the "Powered by Relay" footer in the widget. */
100
+ whiteLabel?: boolean
101
+ /** White-label: serve/embed the widget from this hostname (e.g.
102
+ * "chat.acmeco.com"). Allowed automatically as a CORS origin for the
103
+ * control-plane API so the widget works from the custom domain. */
104
+ customDomain?: string
105
+ }
106
+ states: string[]
107
+ initialState: string
108
+ version: number
109
+ welcomeMessage?: string // first message guests see when opening the widget
110
+ operatingHours?: OperatingHoursSlot[] // empty/absent = always open
111
+ offlineMessage?: string // shown outside operating hours instead of chat
112
+ createdAt: number
113
+ updatedAt: number
114
+ }
@@ -0,0 +1,35 @@
1
+ import { encode as mpEncode, decode as mpDecode } from '@msgpack/msgpack'
2
+ import type { ClientFrame, ServerFrame } from './frames.js'
3
+
4
+ export type AnyFrame = ClientFrame | ServerFrame
5
+
6
+ const CLIENT_FRAME_TYPES: ReadonlySet<ClientFrame['type']> = new Set([
7
+ 'auth', 'open', 'send', 'sync', 'history', 'read', 'typing', 'react', 'edit', 'delete', 'invoke', 'pubkey', 'ping', 'annotate', 'annotate_clear',
8
+ ])
9
+
10
+ /** True if a decoded frame is one a client is allowed to send. The server uses
11
+ * this to reject server-only frame types before dispatch, so a malicious or
12
+ * buggy client can't reach an unexpected handler path. */
13
+ export function isClientFrame(frame: AnyFrame): frame is ClientFrame {
14
+ return CLIENT_FRAME_TYPES.has(frame.type as ClientFrame['type'])
15
+ }
16
+
17
+ /** Encode a frame to a binary msgpack payload for the wire. */
18
+ export function encodeFrame(frame: AnyFrame): Uint8Array {
19
+ return mpEncode(frame)
20
+ }
21
+
22
+ /** Decode a binary payload into a frame. Returns null on any malformed input or
23
+ * anything lacking a string `type`, so a bad frame can never crash the handler
24
+ * — the boundary validates `type` before trusting the rest. */
25
+ export function decodeFrame(bytes: Uint8Array): AnyFrame | null {
26
+ let value: unknown
27
+ try {
28
+ value = mpDecode(bytes)
29
+ } catch {
30
+ return null
31
+ }
32
+ if (typeof value !== 'object' || value === null) return null
33
+ if (typeof (value as { type?: unknown }).type !== 'string') return null
34
+ return value as AnyFrame
35
+ }
@@ -0,0 +1,104 @@
1
+ import type {
2
+ ConversationId, MessageId, ProfileId, SubjectId, TenantId, UserId,
3
+ } from './ids.js'
4
+ import type { ActionId } from './ids.js'
5
+
6
+ // ── Message content (discriminated union) ─────────────────────────────────────
7
+ // The runtime stays generic by never hard-coding business content: a message is
8
+ // one of a small, fixed set of shapes. `card`/`form`/`system` are how action
9
+ // results and structured prompts render — they subsume most "rich messaging"
10
+ // features without a per-feature content zoo.
11
+
12
+ export interface CardField { label: string; value: string }
13
+
14
+ /** A reference to an action a card/quick-reply can invoke. */
15
+ export interface InlineActionRef { actionId: ActionId; label: string }
16
+
17
+ export type MessageContent =
18
+ | { kind: 'text'; text: string; enc?: boolean; iv?: string }
19
+ | { kind: 'attachment'; url: string; mime: string; name?: string; size?: number }
20
+ | { kind: 'card'; title?: string; body?: string; fields?: CardField[]; actions?: InlineActionRef[] }
21
+ | { kind: 'form'; prompt: string; actionId: ActionId }
22
+ | { kind: 'system'; event: string; data?: Record<string, string | number | boolean> }
23
+ | { kind: 'appointment'; title: string; startIso: string; endIso: string; location?: string; description?: string; googleUrl: string; icalUrl: string; confirmed?: boolean }
24
+
25
+ export type SenderRole = 'guest' | 'agent' | 'system' | 'bot'
26
+
27
+ // ── Message ───────────────────────────────────────────────────────────────────
28
+ // Ordering is by `seq` (server-assigned, monotonic per conversation), never by
29
+ // `ts`. `ts` is wall-clock for display only. This kills the reorder/duplicate/
30
+ // lost-on-reconnect class of bugs that millisecond-timestamp ordering caused.
31
+
32
+ export interface Message {
33
+ id: MessageId
34
+ conversationId: ConversationId
35
+ seq: number
36
+ senderId: UserId
37
+ senderRole: SenderRole
38
+ content: MessageContent
39
+ ts: number
40
+ replyToId?: MessageId
41
+ editedAt?: number
42
+ deletedAt?: number
43
+ reactions?: Record<string, UserId[]>
44
+ internal?: boolean // true = internal note, only visible to agents
45
+ }
46
+
47
+ // ── Conversation (the room; messages partition by conversationId) ─────────────
48
+ // The room is the conversation, NOT the subject. Two guests discussing the same
49
+ // subject get two conversations. `subjectId` is a nullable reference, never part
50
+ // of the room identity — so "one thread per (guest, subject)" is enforced as
51
+ // app logic at open-time, and many-threads-per-subject stays possible for free.
52
+
53
+ export interface Conversation {
54
+ id: ConversationId
55
+ tenantId: TenantId
56
+ profileId: ProfileId // behavior profile → which actions this room has
57
+ subjectId?: SubjectId // optional: the thing it's about
58
+ guestId: UserId // the end-user
59
+ participants: UserId[] // guest + any assigned agents (membership = authz)
60
+ assignedAgentId?: UserId // routing/ownership
61
+ aiActive?: boolean // staff assigned the AI to answer this room
62
+ state: string // conversation state-machine state
63
+ firstResponseAt?: number // first agent reply ts (SLA)
64
+ csat?: number // satisfaction score 1–5 (set on resolution)
65
+ lastSeq: number // highest seq assigned in this conversation
66
+ tags?: string[] // macro/manual tags (e.g. "refund", "vip")
67
+ /** Live sentiment of the guest's most recent message (best-effort, async). */
68
+ sentiment?: 'positive' | 'neutral' | 'frustrated'
69
+ /** -1 (very frustrated) .. +1 (very positive); paired with `sentiment`. */
70
+ sentimentScore?: number
71
+ /** Set once an SLA-breach escalation macro has fired, so it only fires once. */
72
+ slaEscalatedAt?: number
73
+ createdAt: number
74
+ updatedAt: number
75
+ }
76
+
77
+ // ── Co-browsing / shared annotation ────────────────────────────────────────---
78
+ // A lightweight shared whiteboard layered over a conversation: agent and guest
79
+ // can draw freehand strokes that both sides see live. Strokes are relayed
80
+ // (not stored as messages) and kept per-conversation so late joiners can catch
81
+ // up via the `opened` frame's `annotations` field.
82
+
83
+ export interface AnnotationPoint { x: number; y: number }
84
+ export interface AnnotationStroke {
85
+ id: string
86
+ points: AnnotationPoint[]
87
+ color: string
88
+ width: number
89
+ by: UserId
90
+ }
91
+
92
+ // ── Subject (the referenced entity — Intercom "custom object") ────────────────
93
+ // Carries shared state (available → reserved → sold) and fields (price, vin…)
94
+ // that actions read/write. Many conversations reference one subject. Never a room.
95
+
96
+ export interface Subject {
97
+ id: SubjectId
98
+ tenantId: TenantId
99
+ title: string
100
+ state: string
101
+ fields: Record<string, string | number | boolean>
102
+ createdAt: number
103
+ updatedAt: number
104
+ }
@@ -0,0 +1,86 @@
1
+ import type {
2
+ ConnectionId, ConversationId, MessageId, ProfileId, SubjectId, UserId,
3
+ } from './ids.js'
4
+ import type { Conversation, Message, MessageContent, Subject, AnnotationStroke } from './entities.js'
5
+ import type { ManifestAction } from './actions.js'
6
+
7
+ // ── Wire protocol ─────────────────────────────────────────────────────────────
8
+ // One shared contract, imported by server + widget + dashboard. A change here is
9
+ // a compile error in every consumer — which is the whole reason this lives in a
10
+ // shared package instead of being hand-copied three times.
11
+
12
+ export type ErrorCode =
13
+ | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'BAD_REQUEST'
14
+ | 'RATE_LIMITED' | 'PAYLOAD_TOO_LARGE' | 'CONFLICT' | 'INTERNAL'
15
+
16
+ export type ClientFrame =
17
+ | { type: 'auth'; token: string }
18
+ // Open an existing conversation, or find-or-create one. Find-or-create keys on
19
+ // (guest, subject) when subjectId is given; otherwise a fresh conversation.
20
+ | { type: 'open'; conversationId?: ConversationId; subjectId?: SubjectId; profileId?: ProfileId }
21
+ | { type: 'send'; conversationId: ConversationId; clientMsgId: string; content: MessageContent; replyToId?: MessageId }
22
+ | { type: 'sync'; conversationId: ConversationId; sinceSeq: number } // catch-up after cursor
23
+ | { type: 'history'; conversationId: ConversationId; beforeSeq: number; limit?: number } // load older
24
+ | { type: 'read'; conversationId: ConversationId; seq: number } // read up to seq
25
+ | { type: 'typing'; conversationId: ConversationId; isTyping: boolean }
26
+ | { type: 'react'; conversationId: ConversationId; messageId: MessageId; emoji: string; remove?: boolean }
27
+ | { type: 'edit'; conversationId: ConversationId; messageId: MessageId; content: MessageContent }
28
+ | { type: 'delete'; conversationId: ConversationId; messageId: MessageId }
29
+ | { type: 'invoke'; conversationId: ConversationId; actionId: string; clientInvokeId: string; inputs?: Record<string, unknown> }
30
+ | { type: 'assign'; conversationId: ConversationId; agentId: UserId | null } // null = unassign
31
+ | { type: 'tag'; conversationId: ConversationId; tag: string; remove?: boolean }
32
+ | { type: 'note'; conversationId: ConversationId; clientMsgId: string; text: string } // internal note
33
+ | { type: 'agent_status'; status: 'online' | 'away' | 'offline' } // agent sets their availability
34
+ // Co-browsing: a freehand stroke (or "clear") on the shared annotation canvas
35
+ // for a subject-anchored conversation. Relayed live to the other participant.
36
+ | { type: 'annotate'; conversationId: ConversationId; stroke: Omit<AnnotationStroke, 'by'> }
37
+ | { type: 'annotate_clear'; conversationId: ConversationId }
38
+ | { type: 'pubkey'; conversationId: ConversationId; key: string }
39
+ // X3DH async E2E: a client uploads a batch of one-time prekeys so peers can
40
+ // encrypt to them while they are offline. The server stores them opaquely and
41
+ // vends one on demand — it never derives or uses the keys.
42
+ | { type: 'uploadPrekeys'; identityKey: string; signedPrekey: string; signedPrekeyId: string; signature: string; oneTimePrekeys: string[] }
43
+ | { type: 'fetchPrekey'; targetUserId: UserId }
44
+ | { type: 'ping' }
45
+
46
+ export type ServerFrame =
47
+ | { type: 'authed'; userId: UserId; connectionId: ConnectionId }
48
+ | { type: 'opened'; conversation: Conversation; subject?: Subject; annotations?: AnnotationStroke[] }
49
+ | { type: 'manifest'; conversationId: ConversationId; actions: ManifestAction[]; version: number; name?: string; theme?: { accent: string }; e2e?: boolean; offline?: boolean; offlineMessage?: string; whiteLabel?: boolean }
50
+ | { type: 'message'; message: Message }
51
+ | { type: 'ack'; clientMsgId: string; messageId: MessageId; seq: number; ts: number }
52
+ | { type: 'delivered'; conversationId: ConversationId; seq: number; to: UserId }
53
+ | { type: 'read'; conversationId: ConversationId; seq: number; by: UserId }
54
+ | { type: 'sync'; conversationId: ConversationId; messages: Message[] }
55
+ | { type: 'history'; conversationId: ConversationId; messages: Message[]; hasMore: boolean }
56
+ | { type: 'typing'; conversationId: ConversationId; userId: UserId; isTyping: boolean }
57
+ | { type: 'reaction'; conversationId: ConversationId; messageId: MessageId; emoji: string; by: UserId; removed: boolean }
58
+ | { type: 'edited'; conversationId: ConversationId; messageId: MessageId; content: MessageContent; editedAt: number }
59
+ | { type: 'deleted'; conversationId: ConversationId; messageId: MessageId; ts: number }
60
+ | { type: 'state'; conversationId: ConversationId; state: string }
61
+ | { type: 'assigned'; conversationId: ConversationId; agentId: UserId | null }
62
+ | { type: 'tagged'; conversationId: ConversationId; tag: string; removed: boolean }
63
+ | { type: 'visitor_count'; count: number } // broadcast to agents: guests currently connected
64
+ | { type: 'agent_status_changed'; agentId: UserId; status: 'online' | 'away' | 'offline' }
65
+ // Live sentiment of a guest's most recent message — relayed to agents only so
66
+ // the inbox can flag frustrated conversations as they happen.
67
+ | { type: 'sentiment'; conversationId: ConversationId; label: 'positive' | 'neutral' | 'frustrated'; score: number }
68
+ // Co-browsing: relay of an annotation stroke / clear to everyone in the room.
69
+ | { type: 'annotation'; conversationId: ConversationId; stroke: AnnotationStroke }
70
+ | { type: 'annotation_clear'; conversationId: ConversationId; by: UserId }
71
+ | { type: 'subjectState'; subjectId: SubjectId; state: string }
72
+ | { type: 'presence'; conversationId: ConversationId; userId: UserId; status: 'online' | 'offline'; lastSeen?: number }
73
+ | { type: 'invoked'; clientInvokeId: string; ok: boolean; error?: string }
74
+ | { type: 'error'; code: ErrorCode; message: string }
75
+ | { type: 'peerkey'; conversationId: ConversationId; userId: UserId; key: string }
76
+ // X3DH bundle vended to a requesting client so they can encrypt to an offline peer.
77
+ // Contains null when the target user has no registered prekeys.
78
+ | { type: 'prekeyBundle'; targetUserId: UserId; bundle: { identityKey: string; signedPrekey: string; signedPrekeyId: string; signature: string; oneTimePrekey?: string } | null }
79
+ | { type: 'pong' }
80
+
81
+ /** Limits referenced by both ends so validation stays consistent. */
82
+ export const LIMITS = {
83
+ MAX_TEXT_LEN: 8_000,
84
+ MAX_HISTORY_LIMIT: 100,
85
+ DEFAULT_HISTORY: 50,
86
+ } as const
@@ -0,0 +1,27 @@
1
+ // Branded identifier types. A plain string can't be passed where a TenantId is
2
+ // expected (and vice-versa), so id-confusion bugs — the kind that caused the
3
+ // `t-1` vs `dev-tenant` mismatch in the previous system — become compile errors.
4
+
5
+ export type Brand<T, B extends string> = T & { readonly __brand: B }
6
+
7
+ export type TenantId = Brand<string, 'TenantId'>
8
+ export type UserId = Brand<string, 'UserId'>
9
+ export type ConversationId = Brand<string, 'ConversationId'>
10
+ export type SubjectId = Brand<string, 'SubjectId'>
11
+ export type MessageId = Brand<string, 'MessageId'>
12
+ export type ActionId = Brand<string, 'ActionId'>
13
+ export type ProfileId = Brand<string, 'ProfileId'>
14
+ export type ConnectionId = Brand<string, 'ConnectionId'>
15
+
16
+ export const asTenantId = (s: string): TenantId => s as TenantId
17
+ export const asUserId = (s: string): UserId => s as UserId
18
+ export const asConversationId = (s: string): ConversationId => s as ConversationId
19
+ export const asSubjectId = (s: string): SubjectId => s as SubjectId
20
+ export const asMessageId = (s: string): MessageId => s as MessageId
21
+ export const asActionId = (s: string): ActionId => s as ActionId
22
+ export const asProfileId = (s: string): ProfileId => s as ProfileId
23
+ export const asConnectionId = (s: string): ConnectionId => s as ConnectionId
24
+
25
+ /** Anonymous guests live under this tenant; they bypass cross-tenant scoping but
26
+ * are still gated by conversation membership. */
27
+ export const ANONYMOUS_TENANT = asTenantId('anonymous')
@@ -0,0 +1,5 @@
1
+ export * from './ids.js'
2
+ export * from './entities.js'
3
+ export * from './actions.js'
4
+ export * from './frames.js'
5
+ export * from './codec.js'
package/src/react.tsx ADDED
@@ -0,0 +1,37 @@
1
+ // react.tsx — React wrapper around mount(). Optional: only needed if you're
2
+ // using React/Next.js. Plain HTML/vanilla JS sites should use mount() directly.
3
+ //
4
+ // Usage:
5
+ // import { ChatWidget } from '@paramms/chat-widget/react'
6
+ // <ChatWidget url="wss://api.paramms.com/ws" profileId="p_xxx" />
7
+ //
8
+ // Next.js (App Router): this component uses browser APIs (WebSocket, DOM) and
9
+ // MUST be rendered client-side. Add the 'use client' directive to whichever
10
+ // file renders it, e.g.:
11
+ // 'use client'
12
+ // import { ChatWidget } from '@paramms/chat-widget/react'
13
+ import { useEffect, useRef } from 'react'
14
+ import { mount, type MountOptions, type WidgetHandle } from './index.js'
15
+
16
+ export type ChatWidgetProps = Omit<MountOptions, 'el'>
17
+
18
+ /** Drop-in React component for the chat widget. Mounts on the client only —
19
+ * safe to use in Next.js as long as the host file/component is marked
20
+ * 'use client' (the widget needs WebSocket + DOM, which don't exist during
21
+ * server-side rendering). */
22
+ export function ChatWidget(props: ChatWidgetProps): JSX.Element {
23
+ const ref = useRef<HTMLDivElement>(null)
24
+ const handleRef = useRef<WidgetHandle | null>(null)
25
+
26
+ useEffect(() => {
27
+ if (!ref.current) return
28
+ handleRef.current = mount({ ...props, el: ref.current })
29
+ return () => { handleRef.current?.close(); handleRef.current = null }
30
+ // Re-mount if the connection target or profile changes; most other props
31
+ // (theme, quick replies) are read once at mount time by design, matching
32
+ // how the vanilla mount() API works.
33
+ // eslint-disable-next-line react-hooks/exhaustive-deps
34
+ }, [props.url, props.profileId, props.subjectId, props.token])
35
+
36
+ return <div ref={ref} style={{ width: '100%', height: '100%' }} />
37
+ }