-
Notifications
You must be signed in to change notification settings - Fork 536
feat: add @hypr/helpchat foundation package #4027
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "name": "@hypr/helpchat", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "exports": { | ||
| ".": "./src/index.ts" | ||
| }, | ||
| "scripts": { | ||
| "typecheck": "tsc --noEmit" | ||
| }, | ||
| "devDependencies": { | ||
| "typescript": "~5.8.3" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,130 @@ | ||||||
| import type { | ||||||
| Contact, | ||||||
| Conversation, | ||||||
| CreateContactRequest, | ||||||
| CreateConversationRequest, | ||||||
| FetchFn, | ||||||
| HelpChatConfig, | ||||||
| Message, | ||||||
| SendMessageRequest, | ||||||
| } from "./types"; | ||||||
|
|
||||||
| function resolveFetch(config: HelpChatConfig): FetchFn { | ||||||
| return config.fetchFn ?? globalThis.fetch; | ||||||
| } | ||||||
|
|
||||||
| function authHeaders(config: HelpChatConfig): Record<string, string> { | ||||||
| const headers: Record<string, string> = { | ||||||
| "Content-Type": "application/json", | ||||||
| }; | ||||||
| if (config.accessToken) { | ||||||
| headers["Authorization"] = `Bearer ${config.accessToken}`; | ||||||
| } | ||||||
| return headers; | ||||||
| } | ||||||
|
|
||||||
| // --------------------------------------------------------------------------- | ||||||
| // Contacts | ||||||
| // --------------------------------------------------------------------------- | ||||||
|
|
||||||
| export async function createContact( | ||||||
| config: HelpChatConfig, | ||||||
| req: CreateContactRequest, | ||||||
| ): Promise<Contact> { | ||||||
| const fetchFn = resolveFetch(config); | ||||||
| const res = await fetchFn(`${config.baseUrl}/support/chatwoot/contact`, { | ||||||
| method: "POST", | ||||||
| headers: authHeaders(config), | ||||||
| body: JSON.stringify(req), | ||||||
| }); | ||||||
| if (!res.ok) { | ||||||
| throw new Error(`createContact failed: ${res.status}`); | ||||||
| } | ||||||
| return res.json() as Promise<Contact>; | ||||||
| } | ||||||
|
|
||||||
| // --------------------------------------------------------------------------- | ||||||
| // Conversations | ||||||
| // --------------------------------------------------------------------------- | ||||||
|
|
||||||
| export async function createConversation( | ||||||
| config: HelpChatConfig, | ||||||
| req: CreateConversationRequest, | ||||||
| ): Promise<{ conversationId: number }> { | ||||||
| const fetchFn = resolveFetch(config); | ||||||
| const res = await fetchFn( | ||||||
| `${config.baseUrl}/support/chatwoot/conversations`, | ||||||
| { | ||||||
| method: "POST", | ||||||
| headers: authHeaders(config), | ||||||
| body: JSON.stringify(req), | ||||||
| }, | ||||||
| ); | ||||||
| if (!res.ok) { | ||||||
| throw new Error(`createConversation failed: ${res.status}`); | ||||||
| } | ||||||
| return res.json() as Promise<{ conversationId: number }>; | ||||||
| } | ||||||
|
|
||||||
| export async function listConversations( | ||||||
| config: HelpChatConfig, | ||||||
| sourceId: string, | ||||||
| ): Promise<Conversation[]> { | ||||||
| const fetchFn = resolveFetch(config); | ||||||
| const params = new URLSearchParams({ source_id: sourceId }); | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Wrong query parameter key The Root CauseIn #[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ListConversationsQuery {
pub source_id: String,
}With const params = new URLSearchParams({ source_id: sourceId });This sends Impact: Every call to
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||
| const res = await fetchFn( | ||||||
| `${config.baseUrl}/support/chatwoot/conversations?${params}`, | ||||||
| { | ||||||
| method: "GET", | ||||||
| headers: authHeaders(config), | ||||||
| }, | ||||||
| ); | ||||||
| if (!res.ok) { | ||||||
| throw new Error(`listConversations failed: ${res.status}`); | ||||||
| } | ||||||
| return res.json() as Promise<Conversation[]>; | ||||||
| } | ||||||
|
|
||||||
| // --------------------------------------------------------------------------- | ||||||
| // Messages | ||||||
| // --------------------------------------------------------------------------- | ||||||
|
|
||||||
| export async function sendMessage( | ||||||
| config: HelpChatConfig, | ||||||
| conversationId: number, | ||||||
| req: SendMessageRequest, | ||||||
| ): Promise<Message> { | ||||||
| const fetchFn = resolveFetch(config); | ||||||
| const res = await fetchFn( | ||||||
| `${config.baseUrl}/support/chatwoot/conversations/${conversationId}/messages`, | ||||||
| { | ||||||
| method: "POST", | ||||||
| headers: authHeaders(config), | ||||||
| body: JSON.stringify(req), | ||||||
| }, | ||||||
| ); | ||||||
| if (!res.ok) { | ||||||
| throw new Error(`sendMessage failed: ${res.status}`); | ||||||
| } | ||||||
| return res.json() as Promise<Message>; | ||||||
| } | ||||||
|
|
||||||
| export async function getMessages( | ||||||
| config: HelpChatConfig, | ||||||
| conversationId: number, | ||||||
| sourceId: string, | ||||||
| ): Promise<Message[]> { | ||||||
| const fetchFn = resolveFetch(config); | ||||||
| const params = new URLSearchParams({ source_id: sourceId }); | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Wrong query parameter key The Root CauseIn axum::extract::Query(params): axum::extract::Query<ListConversationsQuery>,
const params = new URLSearchParams({ source_id: sourceId });This sends Impact: Every call to
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||
| const res = await fetchFn( | ||||||
| `${config.baseUrl}/support/chatwoot/conversations/${conversationId}/messages?${params}`, | ||||||
| { | ||||||
| method: "GET", | ||||||
| headers: authHeaders(config), | ||||||
| }, | ||||||
| ); | ||||||
| if (!res.ok) { | ||||||
| throw new Error(`getMessages failed: ${res.status}`); | ||||||
| } | ||||||
| return res.json() as Promise<Message[]>; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,68 @@ | ||||||||||||||
| import type { AgentMessage, FetchFn, HelpChatConfig } from "./types"; | ||||||||||||||
|
|
||||||||||||||
| export interface EventStreamOptions { | ||||||||||||||
| config: HelpChatConfig; | ||||||||||||||
| conversationId: number; | ||||||||||||||
| pubsubToken: string; | ||||||||||||||
| onMessage: (message: AgentMessage) => void; | ||||||||||||||
| signal?: AbortSignal; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Connect to the SSE event stream for real-time human agent messages. | ||||||||||||||
| * | ||||||||||||||
| * The backend proxies Chatwoot's ActionCable WebSocket into a plain SSE | ||||||||||||||
| * stream at `/support/chatwoot/conversations/{id}/events`. | ||||||||||||||
| * | ||||||||||||||
| * Resolves when the stream ends; rejects on connection error. | ||||||||||||||
| */ | ||||||||||||||
| export async function connectEventStream( | ||||||||||||||
| options: EventStreamOptions, | ||||||||||||||
| ): Promise<void> { | ||||||||||||||
| const { config, conversationId, pubsubToken, onMessage, signal } = options; | ||||||||||||||
|
|
||||||||||||||
| const fetchFn: FetchFn = config.fetchFn ?? globalThis.fetch; | ||||||||||||||
|
|
||||||||||||||
| const params = new URLSearchParams({ pubsub_token: pubsubToken }); | ||||||||||||||
| const url = `${config.baseUrl}/support/chatwoot/conversations/${conversationId}/events?${params}`; | ||||||||||||||
|
|
||||||||||||||
| const headers: Record<string, string> = { Accept: "text/event-stream" }; | ||||||||||||||
| if (config.accessToken) { | ||||||||||||||
| headers["Authorization"] = `Bearer ${config.accessToken}`; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const res = await fetchFn(url, { method: "GET", headers, signal }); | ||||||||||||||
|
|
||||||||||||||
| if (!res.ok || !res.body) { | ||||||||||||||
| throw new Error(`event stream failed: ${res.status}`); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const reader = res.body.getReader(); | ||||||||||||||
| const decoder = new TextDecoder(); | ||||||||||||||
| let buffer = ""; | ||||||||||||||
|
|
||||||||||||||
| for (;;) { | ||||||||||||||
| const { done, value } = await reader.read(); | ||||||||||||||
| if (done) break; | ||||||||||||||
|
|
||||||||||||||
| buffer += decoder.decode(value, { stream: true }); | ||||||||||||||
| const parts = buffer.split("\n\n"); | ||||||||||||||
| buffer = parts.pop() ?? ""; | ||||||||||||||
|
|
||||||||||||||
| for (const part of parts) { | ||||||||||||||
| const dataLine = part | ||||||||||||||
| .split("\n") | ||||||||||||||
| .find((line) => line.startsWith("data: ")); | ||||||||||||||
| if (!dataLine) continue; | ||||||||||||||
|
|
||||||||||||||
| try { | ||||||||||||||
| const payload = JSON.parse(dataLine.slice(6)) as AgentMessage; | ||||||||||||||
| if (payload.content) { | ||||||||||||||
| onMessage(payload); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+60
to
+62
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The check Fix: if (payload.content !== undefined) {
onMessage(payload);
}Or if empty strings should genuinely be filtered: if (payload.content && payload.content.trim()) {
onMessage(payload);
}
Suggested change
Spotted by Graphite Agent |
||||||||||||||
| } catch { | ||||||||||||||
| // skip malformed SSE frames | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| export type { | ||
| AgentMessage, | ||
| Contact, | ||
| Conversation, | ||
| CreateContactRequest, | ||
| CreateConversationRequest, | ||
| FetchFn, | ||
| HelpChatConfig, | ||
| Message, | ||
| SendMessageRequest, | ||
| } from "./types"; | ||
|
|
||
| export { | ||
| createContact, | ||
| createConversation, | ||
| getMessages, | ||
| listConversations, | ||
| sendMessage, | ||
| } from "./client"; | ||
|
|
||
| export { connectEventStream } from "./events"; | ||
| export type { EventStreamOptions } from "./events"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| /** Custom fetch function signature for platform-agnostic HTTP. */ | ||
| export type FetchFn = typeof globalThis.fetch; | ||
|
|
||
| /** Configuration for connecting to the helpchat backend. */ | ||
| export interface HelpChatConfig { | ||
| /** Base URL of the support API (e.g. "https://api.example.com"). */ | ||
| baseUrl: string; | ||
| /** Optional auth token included as Bearer header. */ | ||
| accessToken?: string | null; | ||
| /** Optional custom fetch implementation (e.g. Tauri HTTP plugin). */ | ||
| fetchFn?: FetchFn; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Request / Response shapes (mirror crates/api-support/src/routes/chatwoot/) | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export interface CreateContactRequest { | ||
| identifier: string; | ||
| name?: string; | ||
| email?: string; | ||
| customAttributes?: Record<string, unknown>; | ||
| } | ||
|
|
||
| export interface Contact { | ||
| sourceId: string; | ||
| pubsubToken: string; | ||
| } | ||
|
|
||
| export interface CreateConversationRequest { | ||
| sourceId: string; | ||
| customAttributes?: Record<string, unknown>; | ||
| } | ||
|
|
||
| export interface Conversation { | ||
| id: number; | ||
| inboxId: string | null; | ||
| } | ||
|
|
||
| export interface SendMessageRequest { | ||
| content: string; | ||
| messageType?: "incoming" | "outgoing"; | ||
| sourceId?: string; | ||
| } | ||
|
|
||
| export interface Message { | ||
| id: string; | ||
| content: string | null; | ||
| messageType: string | null; | ||
| createdAt: string | null; | ||
| } | ||
|
|
||
| export interface AgentMessage { | ||
| content: string; | ||
| senderName: string; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "include": ["src"], | ||
| "compilerOptions": { | ||
| "target": "ES2020", | ||
| "lib": ["ES2023", "DOM"], | ||
| "module": "ESNext", | ||
| "skipLibCheck": true, | ||
| "moduleResolution": "bundler", | ||
| "isolatedModules": true, | ||
| "noEmit": true, | ||
| "strict": true | ||
| } | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚩 Backend inconsistency: query structs use different serde conventions
This is a pre-existing backend issue that directly caused the bugs in this PR.
ListConversationsQuery(crates/api-support/src/routes/chatwoot/conversation.rs:54) has#[serde(rename_all = "camelCase")]meaning it expectssourceIdin query params, whileConversationEventsQuery(crates/api-support/src/routes/chatwoot/events.rs:12-15) has no rename attribute and expectspubsub_token. This inconsistency is confusing — query parameters are conventionally snake_case or flat lowercase. It may be worth standardizing the backend query structs to not use camelCase rename (or at least documenting the convention), since the utoipaparams()annotations atcrates/api-support/src/routes/chatwoot/conversation.rs:69still document the param assource_idin the OpenAPI spec, which further misleads consumers.Was this helpful? React with 👍 or 👎 to provide feedback.