@rizvanua/contact-chat
v0.1.3
Published
Telegram-backed contact-chat widget: framework-agnostic server core, headless React hooks, and unstyled UI.
Readme
@rizvanua/contact-chat
Telegram-backed contact-chat widget: framework-agnostic server core, headless React hooks, and unstyled UI reference components.
Install
pnpm add @rizvanua/contact-chat
# or
npm install @rizvanua/contact-chat
# or
yarn add @rizvanua/contact-chatPeer dependencies — install alongside the package:
# Required for the server store (Upstash Redis):
pnpm add @upstash/redis
# Required for React hooks:
pnpm add react react-dom jotai
# Optional for the /ui reference components (headless dialog, icons, class merging):
pnpm add @headlessui/react lucide-react tailwind-mergeNode 18+ is required (Web fetch, crypto.randomUUID).
Quick start (server)
import {
createChatServer,
upstashRedisStore,
telegramTransport,
} from '@rizvanua/contact-chat/server';
const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
if (!url || !token) throw new Error('Redis is not configured.');
export const chatServer = createChatServer({
store: upstashRedisStore({ url, token }),
// The transport owns the webhook secret — pass it here, not to createChatServer.
transport: telegramTransport({
botToken: process.env.TELEGRAM_BOT_TOKEN ?? '',
chatId: process.env.TELEGRAM_CHAT_ID ?? '',
webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '',
}),
ipHashSalt: process.env.CHAT_IP_HASH_SALT ?? '',
});For local dev and demos without Redis, use inMemoryStore() instead:
import { createChatServer, inMemoryStore, telegramTransport } from '@rizvanua/contact-chat/server';
export const chatServer = createChatServer({
store: inMemoryStore(),
// The transport owns the webhook secret — pass it here, not to createChatServer.
transport: telegramTransport({ botToken: '...', chatId: '...', webhookSecret: '...' }),
ipHashSalt: 'dev-salt',
});Server
All handlers are Web-standard (request: Request) => Promise<Response>. Use toNextRoute to wire them into a Next.js App Router project.
Route files
app/api/chat/send/route.ts
import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
import { chatServer } from '@/lib/chat-server';
export const POST = toNextRoute(chatServer.send);
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';app/api/chat/poll/route.ts
import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
import { chatServer } from '@/lib/chat-server';
export const GET = toNextRoute(chatServer.poll);
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';app/api/chat/telegram-webhook/route.ts
import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
import { chatServer } from '@/lib/chat-server';
export const POST = toNextRoute(chatServer.webhook);
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';See examples/nextjs-app/ for the complete reference wiring.
Env resolution
The consumer's app resolves credentials — the package never reads process.env directly.
The Vercel Upstash integration provisions KV_REST_API_URL / KV_REST_API_TOKEN. The standalone Upstash integration uses UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN. Support both with || (not ??), because vercel env pull can write blank entries:
const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
if (!url || !token) throw new Error('Redis is not configured.');Full set of env vars the consumer configures:
| Variable | Description |
| --- | --- |
| UPSTASH_REDIS_REST_URL / KV_REST_API_URL | Redis REST URL (dual Upstash pair) |
| UPSTASH_REDIS_REST_TOKEN / KV_REST_API_TOKEN | Redis REST token (dual Upstash pair) |
| TELEGRAM_BOT_TOKEN | Bot token from BotFather |
| TELEGRAM_CHAT_ID | Telegram group/supergroup numeric ID |
| TELEGRAM_WEBHOOK_SECRET | Secret for webhook verification |
| CHAT_IP_HASH_SALT | Salt for hashing visitor IPs |
setWebhook
Register your webhook URL with Telegram once (or whenever your domain changes). See Telegram docs: setWebhook.
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-H 'Content-Type: application/json' \
-d "{\"url\":\"https://your.domain/api/chat/telegram-webhook\",\"secret_token\":\"$TELEGRAM_WEBHOOK_SECRET\",\"allowed_updates\":[\"message\"]}"Telegram + Upstash setup
- Telegram bot: create via @BotFather; add the bot to your supergroup and enable forum topics in the group settings.
- Upstash Redis: Upstash Console; or via the Vercel Upstash integration which provisions
KV_REST_API_*variables automatically.
React (client)
Import from @rizvanua/contact-chat/react. Requires react, react-dom, and jotai as peers.
<ChatClientProvider>
Wrap your app (or the subtree containing the chat widget) with ChatClientProvider. All props are optional — defaults work out of the box.
// app/providers.tsx (Next.js App Router example)
'use client';
import { ChatClientProvider } from '@rizvanua/contact-chat/react';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ChatClientProvider
basePath="/api/chat"
chime={process.env.NEXT_PUBLIC_CHAT_CHIME !== 'false'}
onEvent={(e) => {
if (e.type === 'chat.send.success') {
// e.g. gtag('event', 'contact_chat_sent')
}
}}
>
{children}
</ChatClientProvider>
);
}| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| basePath | string | '/api/chat' | Base path for the three route files (/send, /poll, /telegram-webhook). |
| sessionStorageKey | string | 'contact-chat-session' | localStorage key for the session id. |
| nameStorageKey | string | 'contact-chat-name' | localStorage key for the visitor's name. |
| chime | boolean | true | Play a two-note synthesized chime when an owner reply arrives. |
| maxMessageLength | number | 1000 | Client-side max message length; should match the server's ChatLimits.maxMessageLength (defaults to DEFAULT_CHAT_LIMITS.maxMessageLength). |
| onEvent | (e: ChatClientEvent) => void | — | Optional analytics callback fired on lifecycle events (chat.open, chat.close, chat.send.attempt, chat.send.success, chat.send.error). |
useChatActions()
Reads chat state and provides action callbacks. Safe to call from any number of components — it contains no polling effects.
'use client';
import { useChatActions } from '@rizvanua/contact-chat/react';
export function MyComposer() {
const { open, setOpen, messages, unread, status, error, name, setName, send, canSend } =
useChatActions();
if (!open) {
return (
<button onClick={() => setOpen(true)}>
Chat {unread > 0 && <span>{unread}</span>}
</button>
);
}
return (
<div>
{messages.map((msg) => (
<p key={msg.id}>{msg.text}</p>
))}
{status === 'error' && <p>{error}</p>}
<button
disabled={!canSend}
onClick={() => send('Hello from my bespoke composer')}
>
Send
</button>
<button onClick={() => setOpen(false)}>Close</button>
</div>
);
}Return shape:
| Field | Type | Description |
| --- | --- | --- |
| open | boolean | Whether the chat panel is open. |
| setOpen | (v: boolean \| ((prev: boolean) => boolean)) => void | Opens or closes the panel. Fires chat.open / chat.close on the onEvent callback. |
| messages | ChatMessage[] | All messages in the session, sorted by server timestamp. |
| unread | number | Count of owner replies that arrived while the panel was closed. |
| status | 'idle' \| 'sending' \| 'error' \| 'unavailable' | Send status. |
| error | string \| null | Human-readable error message when status === 'error'. |
| name | string \| null | The visitor's name once set. |
| setName | (raw: string) => boolean | Sanitizes and stores the name; returns false when the name is unusable (empty after stripping). |
| send | (text: string) => Promise<void> | Sends a message; appends it optimistically. |
| canSend | boolean | false while a send is in-flight (status === 'sending'). |
useChatSync()
Owns the polling lifecycle: hydrates session/name from localStorage, runs adaptive polling (4 s while open, 15 s when idle, pauses for backgrounded tabs), and fires the chime on owner replies.
Mount this exactly once in the component tree. Two mounts run two racing polling loops on the same cursor. The simplest placement is inside <ChatLauncher> (which calls it internally), or inside a single <providers.tsx> component that is guaranteed to mount once.
// app/providers.tsx — mount useChatSync once alongside the provider
'use client';
import { ChatClientProvider } from '@rizvanua/contact-chat/react';
import { useChatSync } from '@rizvanua/contact-chat/react';
function ChatSync() {
useChatSync();
return null;
}
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ChatClientProvider>
<ChatSync />
{children}
</ChatClientProvider>
);
}If you use <ChatLauncher> from the /ui entry, skip the manual useChatSync mount — ChatLauncher already calls it.
Advanced exports
mergeMessages, the Jotai atoms (chatOpenAtom, chatMessagesAtom, etc.), and playChime are all exported from @rizvanua/contact-chat/react for advanced consumers who want to integrate the state into an existing Jotai store or trigger the chime from custom logic.
UI (unstyled reference components)
Import from @rizvanua/contact-chat/ui. Requires @headlessui/react, lucide-react, and tailwind-merge as optional peers.
pnpm add @headlessui/react lucide-react tailwind-mergeAll components ship with sensible Tailwind defaults (dark theme, purple accent). Override any class with the classNames prop — classes are merged via tailwind-merge so specificity conflicts are resolved correctly.
<ChatLauncher>
A floating button that opens the chat panel. Internally calls useChatSync(), so mount it exactly once.
import { MessageCircle } from 'lucide-react';
import { ChatLauncher } from '@rizvanua/contact-chat/ui';
export default function Page() {
return (
<main>
<h1>My page</h1>
<ChatLauncher
icon={<MessageCircle size={18} />}
labels={{ button: 'Talk to me' }}
classNames={{ button: 'bg-blue-600 hover:bg-blue-700' }}
onEvent={(e) => console.log(e.type)}
/>
</main>
);
}Props:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| labels.button | string | 'Contact me' | Button text. |
| labels.unreadAriaLabel | (n: number) => string | (n) => `${n} unread ${n === 1 ? 'reply' : 'replies'}` | ARIA label for the unread badge. |
| classNames.button | string | — | Extra classes merged onto the button. |
| classNames.unreadBadge | string | — | Extra classes merged onto the badge dot. |
| icon | React.ReactNode | — | Icon rendered inside the button. Pass <MessageCircle /> or any SVG. |
| onEvent | (e: { type: 'ui.launcher.open' }) => void | — | Fires when the launcher button is clicked. |
| children | React.ReactNode | <ChatDialog /> | Custom dialog; pass null to skip the dialog mount. |
<ChatDialog>
The chat panel: header with name, message list, name gate, and composer. Uses @headlessui/react <Dialog> with transition.
import { ChatDialog } from '@rizvanua/contact-chat/ui';
// Used inside a ChatLauncher's children prop to provide a custom dialog:
<ChatLauncher>
<ChatDialog
labels={{
title: 'Get in touch',
greeting: (name) => `Hey ${name}! Drop me a line.`,
unavailableFallback: <a href="mailto:[email protected]">Email me instead</a>,
}}
classNames={{ panel: 'max-w-sm' }}
/>
</ChatLauncher>Key props:
| Prop | Description |
| --- | --- |
| labels.title | Header title. Default: 'Chat'. |
| labels.titleWithName | Suffix appended when name is set. Default: (n) => ` · ${n}`. |
| labels.greeting | Shown once name is accepted. Default: (n) => `Hi ${n} — type your message here… I usually reply within a few minutes.`. |
| labels.composerPlaceholder | (nameOk: boolean) => string. Default: (nameOk) => nameOk ? 'Type a message…' : 'Enter your name first'. |
| labels.messageAriaLabel | ARIA label on the textarea. Default: 'Message'. |
| labels.sendAriaLabel | ARIA label on the send button. Default: 'Send message'. |
| labels.closeAriaLabel | ARIA label on the close (X) button. Default: 'Close chat'. |
| labels.unavailableFallback | React.ReactNode shown on 'unavailable' status. Default: a "Chat is unreachable right now" paragraph. |
| classNames.* | Per-element class overrides: overlay, panel, header, title, closeButton, body, greeting, unavailable, errorText, composer, honeypot, textarea, sendButton. |
| renderMessage | (message: ChatMessage) => React.ReactNode — replaces <MessageBubble>. |
| nameGate | React.ReactNode — replaces <NameGate>. |
<NameGate>
An inline name-entry form. Rendered automatically by <ChatDialog> until the visitor enters a valid name. Pass nameGate={<NameGate labels={{ prompt: 'Who are you?' }} />} to <ChatDialog> to customise it.
| Prop | Description |
| --- | --- |
| labels.prompt | Default: "Before we start — what's your name?" |
| labels.placeholder | Default: 'Your name' |
| labels.submitButton | Default: 'Start chat' |
| labels.inputAriaLabel | Default: 'Your name' |
| labels.error | Default: 'Please enter your name.' |
| classNames.* | Per-element class overrides (container, prompt, inputRow, input, submitButton, error). |
<MessageBubble>
Renders a single ChatMessage as a chat bubble (visitor right-aligned purple, owner left-aligned grey). Usually rendered internally by <ChatDialog>, but exported for consumers who pass a custom renderMessage.
import { MessageBubble } from '@rizvanua/contact-chat/ui';
renderMessage={(msg) => <MessageBubble message={msg} classNames={{ bubble: 'text-base' }} />}Working styled reference
See examples/nextjs-app/app/page.tsx for a complete, styled implementation using all four components together.
Configuration
Set up Telegram
You need a bot and a group with Topics enabled. Each visitor conversation becomes its own topic thread, so replying in-thread can never mis-route.
- Create the bot. Message @BotFather, send
/newbot, and follow the prompts. Copy the token it gives you — this isTELEGRAM_BOT_TOKEN. - Create a group and enable Topics. Make a new private group, open Group Settings → Topics, and turn Topics on.
- Add the bot as an admin. Add your bot to the group and promote it to admin with the Manage Topics permission (it needs to create/rename topic threads).
- Get the group's chat id. The target group id is a negative integer. The simplest way: add @RawDataBot to the group temporarily and read
message.chat.idfrom its dump (supergroups look like-1001234567890), then remove it. Set this asTELEGRAM_CHAT_ID. - Pick a webhook secret. Generate a random string (e.g.
openssl rand -hex 32) and set it asTELEGRAM_WEBHOOK_SECRET. Telegram sends it back in theX-Telegram-Bot-Api-Secret-Tokenheader so the webhook can reject forged requests. - Register the webhook against your production URL (one bot supports one webhook URL). Run this once after deploying:
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
-d "url=https://your-domain.com/api/chat/telegram-webhook" \
-d "secret_token=<TELEGRAM_WEBHOOK_SECRET>"Verify it stuck with curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo".
Set up Redis (Upstash)
Storage is HTTP-based Upstash Redis (no serverless connection pooling issues, native per-key TTL). Transcripts carry a 7-day sliding TTL, so retention is automatic with no cleanup job.
- Create a database. Sign in at console.upstash.com, create a Redis database (pick a region close to your deployment). The free tier far exceeds this widget's traffic.
- Copy the REST credentials. From the database page, copy the REST URL and REST token — these are
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKEN. - Or use the Vercel integration. If you provision Upstash through the Vercel Marketplace, it writes
KV_REST_API_URL/KV_REST_API_TOKENinstead. Your app should prefer theUPSTASH_*pair and fall back to theKV_REST_API_*pair with||(not??), sincevercel env pullcan write blank entries. - Set a hash salt. Generate a random
CHAT_IP_HASH_SALT(e.g.openssl rand -hex 16). It salts the SHA-256 IP hashing used for rate limiting — raw IPs are never stored.
For local dev and demos you can skip Redis entirely and use
inMemoryStore()(see the Quick start above).
Env vars
Env vars are the consumer's responsibility — the package never reads process.env directly. Configure these in your .env.local / Vercel environment settings:
UPSTASH_REDIS_REST_URL=...
UPSTASH_REDIS_REST_TOKEN=...
# or, if using the Vercel Upstash integration:
KV_REST_API_URL=...
KV_REST_API_TOKEN=...
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
TELEGRAM_WEBHOOK_SECRET=...
CHAT_IP_HASH_SALT=...Feature flags / hooks
Feature flags and analytics hooks are handled via the package's own extension points:
- Feature flag / gtag events — pass
onEventto<ChatClientProvider>or<ChatLauncher>. - Custom classNames — pass
classNamesto<ChatLauncher>/<ChatDialog>to apply your design system classes. - Unavailability fallback — pass
labels.unavailableFallbackto<ChatDialog>(e.g. an email link).
Development
See CONTRIBUTING.md for the build, test, and release workflow.
License
MIT — see LICENSE.
