@webzaytsev/support-sdk
v1.1.1
Published
TypeScript SDK for Support Bot Web API
Readme
@webzaytsev/support-sdk
TypeScript SDK for the Support Bot Web API — a Telegram-based customer support system with a REST/SSE web interface.
Note: The support bot itself is a private, commercial product. This SDK is published publicly so that frontend teams can integrate the support widget without needing access to the backend source code.
Installation
pnpm add @webzaytsev/support-sdk
# or
npm install @webzaytsev/support-sdkRuntime requirements: Node.js 18+ or any modern browser (Fetch API). The /react entry additionally requires React ≥ 18 (peer dependency, optional).
Quick Start — High-level Chat Client
The createSupportChat factory wraps all real-time transport, state management, and error handling so you don't have to.
0. Configure once at app startup
import { configure } from '@webzaytsev/support-sdk';
configure({
baseUrl: 'https://sup.example.com',
token: () => getOrRefreshJwt(), // called per-request; cache + refresh logic lives here
});Always use an async token provider for SPAs — it handles token expiry transparently.
Never put the token inlocalStorage; keep it in memory only.
1. Issue a JWT (server-side only)
Your backend exchanges the server-to-server API key for a short-lived user JWT. Never call this from the browser — it requires WEB_API_KEY which must stay secret.
// Called from YOUR backend, not the browser
import { webAuthControllerIssueToken } from '@webzaytsev/support-sdk';
const { data } = await webAuthControllerIssueToken({
headers: { 'X-Internal-Api-Key': process.env.WEB_API_KEY },
body: {
userId: 'user_42',
userProfile: { name: 'Ivan Petrov', email: '[email protected]' },
},
});
// data.token — pass to the frontend securely (e.g. via your session cookie)
// data.expiresAt — ISO timestamp, use for proactive refresh2. Create a chat session
import { createSupportChat } from '@webzaytsev/support-sdk/chat';
const chat = createSupportChat({
// All options are optional — these are the defaults:
pollIntervalMs: 5000, // polling fallback interval
backoffBaseMs: 1000, // initial reconnect delay
backoffMaxMs: 30000, // maximum reconnect delay (with jitter)
maxSseAttempts: 5, // SSE failures before switching to polling
sseRetryAfterPollingMs: 60000, // how long to stay in polling before probing SSE again
maxMessages: 200, // messages kept in memory
enablePolling: true, // enable polling fallback when SSE is unavailable
});3. Subscribe and render
const unsub = chat.subscribe(() => {
const { conversation, messages, status, error, isSending } = chat.getSnapshot();
renderWidget({ conversation, messages, status, error, isSending });
});4. Start (hydrate + connect realtime)
await chat.start();
// Fetches the current conversation and message history, then opens an SSE stream.
// Falls back to polling automatically if SSE is unavailable (e.g. buffering proxy).5. Send a message
// Plain text
await chat.send('My subscription is not working');
// With file attachments (validated client-side: jpeg/png/webp, ≤10 MB)
await chat.send('See the screenshot', { files: [selectedFile] });Messages appear immediately as "pending" (optimistic update), then confirmed once the server responds.
6. Conversation actions
await chat.escalate(); // User pressed «Передать оператору»
await chat.resolve(); // User pressed «Проблема решена»Error codes (conversation actions):
CONVERSATION_CLOSED(409) — returned byresolve()when the ticket is already closed server-side. Safe to ignore or surface as a no-op toast; no widget change required.escalate()on a closed ticket is valid: the backend explicitly reopens the Telegram topic and labels the moderator notification as a reopen.
7. Cleanup
// On widget unmount / page unload:
unsub();
chat.destroy(); // closes SSE, clears timers, releases all resourcesReact Integration
Recommended: @webzaytsev/support-sdk/react
The /react entry ships a ready-made hook. Requires React ≥ 18.
import { useSupportChat } from '@webzaytsev/support-sdk/react';
function SupportWidget() {
const { conversation, messages, status, error, isSending, send } = useSupportChat();
// render...
}useSupportChat accepts the same optional ChatSessionOptions as createSupportChat:
const chat = useSupportChat({ maxMessages: 100, pollIntervalMs: 3000 });The hook is StrictMode-safe (session restarts cleanly on double-mount), SSR-safe (returns an idle snapshot on the server with no network calls), and passes react-hooks/exhaustive-deps / react-hooks/rules-of-hooks.
Build-your-own hook (framework-agnostic, no /react entry)
If you prefer not to add the /react entry, you can wire the session manually using useState as the lazy initialiser — this is the only lint-clean way to hold a stable instance without reading or writing a ref during render:
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
import { createSupportChat } from '@webzaytsev/support-sdk/chat';
import type { ChatSessionOptions } from '@webzaytsev/support-sdk/chat';
function useSupportChatManual(options?: ChatSessionOptions) {
// Lazy initialiser — runs once, never touches ref.current during render
const [chat] = useState(() => createSupportChat(options));
useEffect(() => {
void chat.start();
return () => chat.destroy();
}, [chat]);
const subscribe = useCallback((cb: () => void) => chat.subscribe(cb), [chat]);
const getSnapshot = useCallback(() => chat.getSnapshot(), [chat]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}Snapshot shape
interface ChatSnapshot {
conversation: ConversationInfoDto | null; // current conversation or null
messages: ChatMessage[]; // chronological, deduped, sorted
status: ConnectionStatus; // 'idle' | 'connecting' | 'online' | 'polling' | 'offline'
error: ChatError | null; // sanitised error (no tokens or PII)
isSending: boolean; // true while any send() is in flight
}
interface ChatMessage extends MessageDto {
deliveryStatus: 'pending' | 'sent' | 'failed';
tempId?: string; // only present on optimistic messages
}Exports
| Import path | Contents |
|---|---|
| @webzaytsev/support-sdk | SDK functions, TypeScript types, configure(), TokenProvider, plugin types |
| @webzaytsev/support-sdk/chat | createSupportChat, ChatSession, snapshot/status types — zero extra dependencies |
| @webzaytsev/support-sdk/react | useSupportChat hook — peer: React ≥ 18 |
| @webzaytsev/support-sdk/client | client singleton, createClient — advanced interceptor access |
| @webzaytsev/support-sdk/zod | Zod schemas for all DTOs — optional, adds ~30 KB |
| @webzaytsev/support-sdk/safe-render | renderSafeHtml via DOMPurify — only if you render HTML content |
Both CJS (require) and ESM (import) are supported.
Security
- Tokens in memory only. Never store the JWT in
localStorageorsessionStorage— XSS can read them. The async token provider pattern (configure({ token: () => getJwt() })) keeps tokens in closure scope. - Authorization header only. The SDK uses
Authorization: Bearerfor all requests including SSE. The?access_token=query fallback has been removed — it leaked tokens into server logs, browser history, andRefererheaders. issueTokenis server-to-server only. It requiresWEB_API_KEYwhich must never reach the browser. Call it exclusively from your backend.- CSRF does not apply. Bearer tokens must be explicitly added to requests; browsers do not auto-attach them the way they do cookies.
- Recommended CSP headers:
connect-src 'self' https://sup.example.com; img-src 'self' https://sup.example.com; script-src 'self'; - Rendering message content: Never insert
message.contentviainnerHTMLwithout sanitising first. Use@webzaytsev/support-sdk/safe-renderor your own DOMPurify call. Render attachments only as<img>with a knownsrc— never embed as SVG or raw HTML.
XSS-safe rendering (optional)
Install DOMPurify if your widget renders content as HTML (links, basic markdown):
pnpm add dompurifyimport { renderSafeHtml } from '@webzaytsev/support-sdk/safe-render';
element.innerHTML = renderSafeHtml(message.content ?? '');
// → Sanitised: strips on* handlers, javascript: URLs, forces rel="noopener noreferrer" on linksSSE Event Types
| type | Description | data fields |
|---|---|---|
| message | New message from moderator or bot | messageId, text, attachmentIds?, createdAt |
| reopened | Conversation reopened after resolution | — |
| escalation_ack | Escalation acknowledged | escalatableMessageId, source |
| closed | Conversation closed by moderator | — |
| heartbeat | Keep-alive ping (~15 s interval) | — |
Recommended widget copy (Russian)
The backend does not ship UI strings for the embeddable widget — your frontend owns button labels and banners. Suggested copy aligned with the Telegram bot:
| UI element | Suggested text (RU) |
|---|---|
| Escalation button | Передать оператору |
| User resolved action | Проблема решена |
| Input placeholder | У меня не работает подписка |
| closed event banner | Ваше обращение закрыто. |
| escalation_ack event banner | Подключаем дополнительного специалиста. |
Show the escalation button when message.escalatableMessageId is present, then call chat.escalate() or POST /web/conversation/:id/escalate.
Migrating from 0.x
| Old (≤0.4.x) | New (1.0.0+) |
|---|---|
| GET /web/tickets/current | GET /web/conversation |
| GET /web/tickets/:id/messages | GET /web/conversation/:id/messages |
| GET /web/tickets/:id/stream | GET /web/conversation/:id/stream |
| POST /web/tickets/:id/escalate | POST /web/conversation/:id/escalate |
| POST /web/tickets/:id/resolve | POST /web/conversation/:id/resolve |
| TicketInfoDto ({ id, status, createdAt, updatedAt }) | ConversationInfoDto ({ id, createdAt, updatedAt } — no status) |
| CurrentTicketResponseDto.ticket | ConversationResponseDto.conversation |
| SendMessageResponseDto.ticketId | SendMessageResponseDto.conversationId |
| WebSseEventDto.ticketId | WebSseEventDto.conversationId |
| ChatSnapshot.ticket (had .status) | ChatSnapshot.conversation (no status) |
| webTicketsController* functions | webConversationController* functions |
There is no open/closed status anymore. Never gate the composer, hide messages, or clear history based on any lifecycle flag — the backend always returns the full conversation regardless of internal ticket state. escalate()/resolve() are always-available soft actions.
Advanced / Low-Level API
Use these only if you need direct control over individual requests (interceptors, custom retry logic, etc.). For most use cases, createSupportChat is sufficient.
Raw SDK functions
import {
webConversationControllerSendMessage,
webConversationControllerGetCurrent,
webConversationControllerGetMessages,
webConversationControllerSseStream,
webConversationControllerEscalate,
webConversationControllerResolveByUser,
webUploadsControllerUpload,
webUploadsControllerGetUpload,
} from '@webzaytsev/support-sdk';Raw client access (interceptors)
import { client } from '@webzaytsev/support-sdk/client';
client.interceptors.request.use((req) => {
req.headers.set('X-Trace-Id', crypto.randomUUID());
return req;
});SSE stream (manual)
import { webConversationControllerSseStream } from '@webzaytsev/support-sdk';
const { stream } = await webConversationControllerSseStream({ path: { id: conversationId } });
for await (const event of stream) {
switch (event.type) {
case 'message':
appendMessage({ id: event.data?.messageId, text: event.data?.text });
break;
case 'heartbeat':
// keep-alive, no action needed
break;
}
}Polling fallback (manual)
let cursor: string | null = null;
setInterval(async () => {
const { data } = await webConversationControllerGetMessages({
path: { id: conversationId },
query: { since: cursor ?? undefined, limit: '50' },
});
if (data.messages.length > 0) {
appendMessages(data.messages); // oldest-first
cursor = data.nextCursor; // string | null
}
}, 5000);Zod schemas
import { zSendMessageDto, zConversationResponseDto } from '@webzaytsev/support-sdk/zod';
const parsed = zSendMessageDto.parse({ text: 'Hello' });
const conversation = zConversationResponseDto.parse(apiResponse);API Reference
| Function | Method | Description |
|---|---|---|
| webAuthControllerIssueToken | POST /web/auth/token | Issue user JWT (server-side, requires API key) |
| webConversationControllerSendMessage | POST /web/messages | Send message (creates or continues conversation) |
| webConversationControllerGetCurrent | GET /web/conversation | Get conversation + message history |
| webConversationControllerGetMessages | GET /web/conversation/:id/messages | Poll messages since cursor |
| webConversationControllerSseStream | GET /web/conversation/:id/stream | SSE real-time event stream |
| webConversationControllerEscalate | POST /web/conversation/:id/escalate | Escalate the conversation |
| webConversationControllerResolveByUser | POST /web/conversation/:id/resolve | Confirm issue is resolved |
| webUploadsControllerUpload | POST /web/uploads | Upload an image (jpeg/png/webp, max 10 MB) |
| webUploadsControllerGetUpload | GET /web/uploads/:id | Stream an uploaded image |
Plugin Types
The extra field in WebProfileDto is an open string key-value bag. Plugins read specific keys from it. Typed helpers are exported from the main package.
Remnawave plugin
The Remnawave plugin (/sub command, ticket info card) looks for a user identifier using this priority order: extra.userId → extra.remnawave_user_id → extra.user_id → Telegram ID (TG channel only).
import type { RemnawaveWebProfile } from '@webzaytsev/support-sdk';
const profile: RemnawaveWebProfile = {
name: 'Ivan Petrov',
email: '[email protected]',
extra: {
userId: 'ddc9c1c9-973c-46d9-acd7-8db629e7bc98',
},
};License
MIT — the SDK is free to use. The support bot backend is a private commercial product.
