simple-support-chat 0.1.1 → 0.2.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/README.md CHANGED
@@ -4,6 +4,8 @@ Embeddable chat widget SDK that routes customer messages to Slack threads. Open-
4
4
 
5
5
  - Zero cost, self-hosted
6
6
  - Slack-native: messages appear as threads in your workspace
7
+ - Bidirectional: team replies in Slack threads show in the chat widget
8
+ - Slack emoji shortcodes (`:heart:`) auto-converted to Unicode (❤️)
7
9
  - Works with any React app (Next.js, Remix, Vite, etc.)
8
10
  - Anonymous and authenticated user support
9
11
  - No external CSS or Tailwind dependency
@@ -70,8 +72,11 @@ Add these to your `.env` file:
70
72
  ```bash
71
73
  SLACK_BOT_TOKEN=xoxb-your-token-here
72
74
  SLACK_CHANNEL_ID=C0123ABCDEF
75
+ SLACK_SIGNING_SECRET=your-signing-secret-here # Required for receiving replies (see "Receiving Replies" section)
73
76
  ```
74
77
 
78
+ The signing secret is found under **Basic Information** > **App Credentials** in your Slack app settings. It is only needed if you want bidirectional messaging (team replies appearing in the widget).
79
+
75
80
  ### Validate Your Token
76
81
 
77
82
  Use the built-in `validateSlackToken` utility to verify your token works before going live:
@@ -128,6 +133,280 @@ export default function App() {
128
133
 
129
134
  That's it! Messages from your users will appear as threaded conversations in your Slack channel.
130
135
 
136
+ ## Receiving Replies
137
+
138
+ By default, simple-support-chat is one-way: users send messages that appear in Slack. To make it bidirectional -- so team replies in Slack appear in the user's chat widget -- you need three additional pieces:
139
+
140
+ 1. A **webhook route** that receives events from Slack when someone replies in a thread
141
+ 2. A **replies endpoint** that the client polls for new replies
142
+ 3. The `repliesUrl` prop on the client widget
143
+
144
+ ### Prerequisites
145
+
146
+ - Completed the [Slack App Setup](#slack-app-setup) above
147
+ - Your Slack app's **Signing Secret** (found under **Basic Information** > **App Credentials** in [api.slack.com/apps](https://api.slack.com/apps))
148
+
149
+ Add the signing secret to your environment variables:
150
+
151
+ ```bash
152
+ SLACK_SIGNING_SECRET=your-signing-secret-here
153
+ ```
154
+
155
+ ### Step 1: Enable Event Subscriptions
156
+
157
+ 1. Go to your app at [api.slack.com/apps](https://api.slack.com/apps)
158
+ 2. Navigate to **Event Subscriptions** in the sidebar
159
+ 3. Toggle **Enable Events** to On
160
+ 4. Set the **Request URL** to `https://your-domain.com/api/support/webhook`
161
+ - Slack will send a verification challenge -- your webhook handler responds automatically
162
+ 5. Under **Subscribe to bot events**, add `message.channels`
163
+ 6. Click **Save Changes**
164
+
165
+ A pre-built manifest is included at [`slack-app-manifest.json`](./slack-app-manifest.json). Replace `YOUR_DOMAIN` with your actual domain and use it when creating or reconfiguring your Slack app.
166
+
167
+ > **Local development:** Slack cannot reach `localhost`. Use a tunnel like [ngrok](https://ngrok.com/) (`ngrok http 3000`) and set the tunnel URL as the Request URL. You can skip webhook setup entirely for local dev -- one-way messaging works without it.
168
+
169
+ ### Step 2: Create a Shared Store
170
+
171
+ The webhook handler, replies handler, and message handler must share the same store instance so that thread mappings and replies are consistent.
172
+
173
+ ```ts
174
+ // lib/support-store.ts (or wherever you keep shared singletons)
175
+ import { InMemoryStore } from "simple-support-chat/server";
176
+
177
+ export const store = new InMemoryStore();
178
+ ```
179
+
180
+ `InMemoryStore` works for local development. For production, implement the `SupportChatStore` interface with your database (see [Custom Storage](#custom-storage-supportchatstore) below).
181
+
182
+ ### Step 3: Set Up Routes
183
+
184
+ #### Next.js App Router
185
+
186
+ ```ts
187
+ // app/api/support/route.ts
188
+ import { createSupportHandler } from "simple-support-chat/server";
189
+ import { store } from "@/lib/support-store";
190
+
191
+ export const POST = createSupportHandler({
192
+ slackBotToken: process.env.SLACK_BOT_TOKEN!,
193
+ slackChannel: process.env.SLACK_CHANNEL_ID!,
194
+ store,
195
+ });
196
+ ```
197
+
198
+ ```ts
199
+ // app/api/support/webhook/route.ts
200
+ import { createWebhookHandler } from "simple-support-chat/server";
201
+ import { store } from "@/lib/support-store";
202
+
203
+ export const POST = createWebhookHandler({
204
+ store,
205
+ signingSecret: process.env.SLACK_SIGNING_SECRET!,
206
+ });
207
+ ```
208
+
209
+ ```ts
210
+ // app/api/support/replies/route.ts
211
+ import { createRepliesHandler } from "simple-support-chat/server";
212
+ import { store } from "@/lib/support-store";
213
+
214
+ export const GET = createRepliesHandler({ store });
215
+ ```
216
+
217
+ #### Express
218
+
219
+ ```ts
220
+ import express from "express";
221
+ import {
222
+ createExpressHandler,
223
+ createExpressWebhookHandler,
224
+ createExpressRepliesHandler,
225
+ InMemoryStore,
226
+ } from "simple-support-chat/server";
227
+
228
+ const app = express();
229
+ const store = new InMemoryStore();
230
+
231
+ // Message handler (uses JSON body parser)
232
+ app.post(
233
+ "/api/support",
234
+ express.json(),
235
+ createExpressHandler({
236
+ slackBotToken: process.env.SLACK_BOT_TOKEN!,
237
+ slackChannel: process.env.SLACK_CHANNEL_ID!,
238
+ store,
239
+ }),
240
+ );
241
+
242
+ // Webhook handler (uses raw body parser for signature verification)
243
+ app.post(
244
+ "/api/support/webhook",
245
+ express.raw({ type: "application/json" }),
246
+ createExpressWebhookHandler({
247
+ store,
248
+ signingSecret: process.env.SLACK_SIGNING_SECRET!,
249
+ }),
250
+ );
251
+
252
+ // Replies handler (GET endpoint for client polling)
253
+ app.get(
254
+ "/api/support/replies",
255
+ createExpressRepliesHandler({ store }),
256
+ );
257
+
258
+ app.listen(3000);
259
+ ```
260
+
261
+ > **Important:** The webhook route must use `express.raw()` (not `express.json()`) so that the raw request body is available for Slack signature verification.
262
+
263
+ ### Step 4: Add repliesUrl to the Client
264
+
265
+ Pass the `repliesUrl` prop to enable reply polling:
266
+
267
+ ```tsx
268
+ <ChatBubble
269
+ apiUrl="/api/support"
270
+ repliesUrl="/api/support/replies"
271
+ />
272
+ ```
273
+
274
+ Or with the modal:
275
+
276
+ ```tsx
277
+ <SupportChatModal
278
+ apiUrl="/api/support"
279
+ repliesUrl="/api/support/replies"
280
+ isOpen={isOpen}
281
+ onClose={close}
282
+ />
283
+ ```
284
+
285
+ When `repliesUrl` is set, the widget polls every 4 seconds while the chat panel is open. Replies appear as left-aligned gray bubbles. Polling stops when the chat is closed.
286
+
287
+ ### Custom Storage (SupportChatStore)
288
+
289
+ For production, implement the `SupportChatStore` interface to persist thread mappings and replies to your database. Here is the interface:
290
+
291
+ ```ts
292
+ interface SupportChatStore {
293
+ saveThread(sessionId: string, threadTs: string, metadata?: Record<string, unknown>): Promise<void>;
294
+ getThreadBySession(sessionId: string): Promise<ThreadRecord | null>;
295
+ getThreadByTs(threadTs: string): Promise<ThreadRecord | null>;
296
+ saveReply(sessionId: string, reply: Reply): Promise<void>;
297
+ getReplies(sessionId: string, since?: string): Promise<Reply[]>;
298
+ }
299
+ ```
300
+
301
+ #### Example: Supabase Implementation
302
+
303
+ First, create the tables:
304
+
305
+ ```sql
306
+ -- Supabase migration
307
+ create table support_chat_threads (
308
+ session_id text primary key,
309
+ thread_ts text not null unique,
310
+ metadata jsonb default '{}',
311
+ created_at timestamptz default now()
312
+ );
313
+
314
+ create table support_chat_replies (
315
+ id text primary key,
316
+ session_id text not null references support_chat_threads(session_id),
317
+ text text not null,
318
+ sender text not null,
319
+ timestamp timestamptz not null,
320
+ thread_ts text not null
321
+ );
322
+
323
+ create index idx_replies_session_timestamp
324
+ on support_chat_replies(session_id, timestamp);
325
+ ```
326
+
327
+ Then implement the store:
328
+
329
+ ```ts
330
+ import type { SupportChatStore, ThreadRecord, Reply } from "simple-support-chat/server";
331
+ import { createClient } from "@supabase/supabase-js";
332
+
333
+ const supabase = createClient(
334
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
335
+ process.env.SUPABASE_SERVICE_ROLE_KEY!,
336
+ );
337
+
338
+ export class SupabaseChatStore implements SupportChatStore {
339
+ async saveThread(sessionId: string, threadTs: string, metadata?: Record<string, unknown>) {
340
+ await supabase.from("support_chat_threads").upsert({
341
+ session_id: sessionId,
342
+ thread_ts: threadTs,
343
+ metadata: metadata ?? {},
344
+ });
345
+ }
346
+
347
+ async getThreadBySession(sessionId: string): Promise<ThreadRecord | null> {
348
+ const { data } = await supabase
349
+ .from("support_chat_threads")
350
+ .select("*")
351
+ .eq("session_id", sessionId)
352
+ .single();
353
+ if (!data) return null;
354
+ return { sessionId: data.session_id, threadTs: data.thread_ts, metadata: data.metadata };
355
+ }
356
+
357
+ async getThreadByTs(threadTs: string): Promise<ThreadRecord | null> {
358
+ const { data } = await supabase
359
+ .from("support_chat_threads")
360
+ .select("*")
361
+ .eq("thread_ts", threadTs)
362
+ .single();
363
+ if (!data) return null;
364
+ return { sessionId: data.session_id, threadTs: data.thread_ts, metadata: data.metadata };
365
+ }
366
+
367
+ async saveReply(sessionId: string, reply: Reply) {
368
+ await supabase.from("support_chat_replies").insert({
369
+ id: reply.id,
370
+ session_id: sessionId,
371
+ text: reply.text,
372
+ sender: reply.sender,
373
+ timestamp: reply.timestamp,
374
+ thread_ts: reply.threadTs,
375
+ });
376
+ }
377
+
378
+ async getReplies(sessionId: string, since?: string): Promise<Reply[]> {
379
+ let query = supabase
380
+ .from("support_chat_replies")
381
+ .select("*")
382
+ .eq("session_id", sessionId)
383
+ .order("timestamp", { ascending: true });
384
+
385
+ if (since) {
386
+ query = query.gt("timestamp", since);
387
+ }
388
+
389
+ const { data } = await query;
390
+ return (data ?? []).map((r) => ({
391
+ id: r.id,
392
+ text: r.text,
393
+ sender: r.sender,
394
+ timestamp: r.timestamp,
395
+ threadTs: r.thread_ts,
396
+ }));
397
+ }
398
+ }
399
+ ```
400
+
401
+ Then pass the store to all handlers:
402
+
403
+ ```ts
404
+ import { SupabaseChatStore } from "@/lib/supabase-chat-store";
405
+
406
+ const store = new SupabaseChatStore();
407
+ // Use this store instance in createSupportHandler, createWebhookHandler, and createRepliesHandler
408
+ ```
409
+
131
410
  ## Configuration
132
411
 
133
412
  ### ChatBubble Props
@@ -135,6 +414,7 @@ That's it! Messages from your users will appear as threaded conversations in you
135
414
  | Prop | Type | Default | Description |
136
415
  |------|------|---------|-------------|
137
416
  | `apiUrl` | `string` | (required) | URL of your support API endpoint |
417
+ | `repliesUrl` | `string` | `undefined` | URL of the replies endpoint. Enables bidirectional chat. |
138
418
  | `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left'` | `'bottom-right'` | Bubble position |
139
419
  | `color` | `string` | `'#2563eb'` | Primary color (bubble, header, sent messages) |
140
420
  | `title` | `string` | `'Support'` | Chat panel header title |
@@ -151,6 +431,7 @@ That's it! Messages from your users will appear as threaded conversations in you
151
431
  | `botName` | `string` | Custom bot display name |
152
432
  | `botIcon` | `string` | Bot icon emoji (e.g., `:speech_balloon:`) |
153
433
  | `onMessage` | `(data) => void` | Callback on each message |
434
+ | `store` | `SupportChatStore` | Pluggable storage backend (defaults to `InMemoryStore`) |
154
435
 
155
436
  ## Identity Integration
156
437
 
@@ -211,6 +492,7 @@ The modal renders centered on desktop (max-width 500px) with a backdrop overlay,
211
492
  | Prop | Type | Default | Description |
212
493
  |------|------|---------|-------------|
213
494
  | `apiUrl` | `string` | (required) | URL of your support API endpoint |
495
+ | `repliesUrl` | `string` | `undefined` | URL of the replies endpoint. Enables bidirectional chat. |
214
496
  | `isOpen` | `boolean` | (required) | Whether the modal is open |
215
497
  | `onClose` | `() => void` | (required) | Callback to close the modal |
216
498
  | `color` | `string` | `'#2563eb'` | Primary color |
@@ -264,9 +546,16 @@ The Express handler uses the same threading logic as the Web API handler. The re
264
546
 
265
547
  | Export | Description |
266
548
  |--------|-------------|
267
- | `createSupportHandler(options)` | Returns a Web API `(Request) => Promise<Response>` handler |
268
- | `createExpressHandler(options)` | Returns an Express `(req, res) => Promise<void>` handler |
549
+ | `createSupportHandler(options)` | Returns a Web API `(Request) => Promise<Response>` handler for messages |
550
+ | `createExpressHandler(options)` | Returns an Express `(req, res) => Promise<void>` handler for messages |
551
+ | `createWebhookHandler(options)` | Returns a Web API handler for Slack event webhooks |
552
+ | `createExpressWebhookHandler(options)` | Returns an Express handler for Slack event webhooks |
553
+ | `createRepliesHandler(options)` | Returns a Web API handler for the replies polling endpoint |
554
+ | `createExpressRepliesHandler(options)` | Returns an Express handler for the replies polling endpoint |
555
+ | `verifySlackSignature(secret, sig, ts, body)` | Verifies a Slack request signature |
556
+ | `InMemoryStore` | Default in-memory implementation of `SupportChatStore` |
269
557
  | `validateSlackToken(token)` | Validates a Slack token via `auth.test` |
558
+ | `emojify(text)` | Converts Slack emoji shortcodes (`:heart:`) to Unicode (❤️) |
270
559
 
271
560
  ### Client Exports (`simple-support-chat`)
272
561
 
@@ -288,7 +577,17 @@ All TypeScript types are exported from both entry points:
288
577
  import type { ChatBubbleProps, ChatUser, SupportChatModalProps, ChatMessage } from "simple-support-chat";
289
578
 
290
579
  // Server types
291
- import type { SupportHandlerOptions, IncomingMessage, HandlerResponse } from "simple-support-chat/server";
580
+ import type {
581
+ SupportHandlerOptions,
582
+ IncomingMessage,
583
+ HandlerResponse,
584
+ SupportChatStore,
585
+ Reply,
586
+ ThreadRecord,
587
+ WebhookHandlerOptions,
588
+ RepliesHandlerOptions,
589
+ RepliesResponse,
590
+ } from "simple-support-chat/server";
292
591
  ```
293
592
 
294
593
  ## Development
@@ -50,13 +50,18 @@ function collectAnonymousContext() {
50
50
  }
51
51
 
52
52
  // src/client/useChatEngine.ts
53
+ var POLL_INTERVAL = 4e3;
53
54
  function useChatEngine({
54
55
  apiUrl,
55
- user
56
+ user,
57
+ repliesUrl,
58
+ isOpen = true
56
59
  }) {
57
60
  const [messages, setMessages] = react.useState([]);
58
61
  const [input, setInput] = react.useState("");
59
62
  const [sending, setSending] = react.useState(false);
63
+ const lastReplyTimestampRef = react.useRef(null);
64
+ const knownReplyIdsRef = react.useRef(/* @__PURE__ */ new Set());
60
65
  const sendMessage = react.useCallback(async () => {
61
66
  const text = input.trim();
62
67
  if (!text || sending) return;
@@ -115,6 +120,44 @@ function useChatEngine({
115
120
  },
116
121
  [sendMessage]
117
122
  );
123
+ react.useEffect(() => {
124
+ if (!repliesUrl || !isOpen) return;
125
+ const sessionId = user?.id ?? getSessionId();
126
+ const fetchReplies = async () => {
127
+ try {
128
+ const params = new URLSearchParams({ sessionId });
129
+ if (lastReplyTimestampRef.current) {
130
+ params.set("since", lastReplyTimestampRef.current);
131
+ }
132
+ const response = await fetch(`${repliesUrl}?${params.toString()}`);
133
+ if (!response.ok) return;
134
+ const data = await response.json();
135
+ if (data.replies.length === 0) return;
136
+ const newReplies = data.replies.filter(
137
+ (r) => !knownReplyIdsRef.current.has(r.id)
138
+ );
139
+ if (newReplies.length === 0) return;
140
+ for (const r of newReplies) {
141
+ knownReplyIdsRef.current.add(r.id);
142
+ }
143
+ const latestTimestamp = newReplies.reduce((latest, r) => {
144
+ return r.timestamp > latest ? r.timestamp : latest;
145
+ }, lastReplyTimestampRef.current ?? "");
146
+ lastReplyTimestampRef.current = latestTimestamp;
147
+ const replyMessages = newReplies.map((r) => ({
148
+ id: r.id,
149
+ text: r.text,
150
+ sender: "received",
151
+ timestamp: new Date(r.timestamp).getTime()
152
+ }));
153
+ setMessages((prev) => [...prev, ...replyMessages]);
154
+ } catch {
155
+ }
156
+ };
157
+ void fetchReplies();
158
+ const intervalId = setInterval(() => void fetchReplies(), POLL_INTERVAL);
159
+ return () => clearInterval(intervalId);
160
+ }, [repliesUrl, isOpen, user]);
118
161
  return { messages, input, setInput, sending, sendMessage, handleKeyDown };
119
162
  }
120
163
  function useColorScheme() {
@@ -184,12 +227,13 @@ function ChatBubble({
184
227
  title = "Support",
185
228
  placeholder = "Type a message...",
186
229
  show = true,
187
- user
230
+ user,
231
+ repliesUrl
188
232
  }) {
189
233
  const [isOpen, setIsOpen] = react.useState(false);
190
234
  const colorScheme = useColorScheme();
191
235
  const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
192
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user });
236
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
193
237
  const [panelState, setPanelState] = react.useState(
194
238
  "closed"
195
239
  );
@@ -414,6 +458,7 @@ function ChatBubble({
414
458
  messages.map((msg) => /* @__PURE__ */ jsxRuntime.jsx(
415
459
  "div",
416
460
  {
461
+ "data-sender": msg.sender,
417
462
  style: {
418
463
  alignSelf: msg.sender === "user" ? "flex-end" : "flex-start",
419
464
  maxWidth: "80%",
@@ -527,9 +572,10 @@ function SupportChatModal({
527
572
  color = "#2563eb",
528
573
  title = "Contact Us",
529
574
  placeholder = "Type a message...",
530
- user
575
+ user,
576
+ repliesUrl
531
577
  }) {
532
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user });
578
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
533
579
  const colorScheme = useColorScheme();
534
580
  const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
535
581
  const modalRef = react.useRef(null);
@@ -721,6 +767,7 @@ function SupportChatModal({
721
767
  messages.map((msg) => /* @__PURE__ */ jsxRuntime.jsx(
722
768
  "div",
723
769
  {
770
+ "data-sender": msg.sender,
724
771
  style: {
725
772
  alignSelf: msg.sender === "user" ? "flex-end" : "flex-start",
726
773
  maxWidth: "80%",