Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/helpchat/package.json
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"
}
}
130 changes: 130 additions & 0 deletions packages/helpchat/src/client.ts
Copy link
Contributor Author

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 expects sourceId in query params, while ConversationEventsQuery (crates/api-support/src/routes/chatwoot/events.rs:12-15) has no rename attribute and expects pubsub_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 utoipa params() annotations at crates/api-support/src/routes/chatwoot/conversation.rs:69 still document the param as source_id in the OpenAPI spec, which further misleads consumers.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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 });
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Wrong query parameter key source_id should be sourceId for listConversations

The listConversations function sends the query parameter as source_id, but the Rust backend's ListConversationsQuery struct uses #[serde(rename_all = "camelCase")], which means axum's Query extractor (backed by serde_urlencoded) expects the camelCase key sourceId.

Root Cause

In crates/api-support/src/routes/chatwoot/conversation.rs:53-57, the query struct is:

#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ListConversationsQuery {
    pub source_id: String,
}

With rename_all = "camelCase", serde deserializes the field source_id from the key sourceId. However, the TypeScript client at packages/helpchat/src/client.ts:74 sends:

const params = new URLSearchParams({ source_id: sourceId });

This sends ?source_id=... in the URL, but the backend expects ?sourceId=.... The request will fail with a deserialization error (likely a 400 or 422 response).

Impact: Every call to listConversations will fail because the backend cannot deserialize the query parameter.

Suggested change
const params = new URLSearchParams({ source_id: sourceId });
const params = new URLSearchParams({ sourceId: sourceId });
Open in Devin Review

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 });
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Wrong query parameter key source_id should be sourceId for getMessages

The getMessages function sends the query parameter as source_id, but the Rust backend reuses ListConversationsQuery (which has #[serde(rename_all = "camelCase")]) for the query extraction, so it expects sourceId.

Root Cause

In crates/api-support/src/routes/chatwoot/message.rs:124, the get_messages handler uses:

axum::extract::Query(params): axum::extract::Query<ListConversationsQuery>,

ListConversationsQuery at crates/api-support/src/routes/chatwoot/conversation.rs:53-57 has #[serde(rename_all = "camelCase")], so the query key must be sourceId. But the TypeScript client at packages/helpchat/src/client.ts:118 sends:

const params = new URLSearchParams({ source_id: sourceId });

This sends ?source_id=... instead of ?sourceId=..., causing the backend to fail to deserialize the query.

Impact: Every call to getMessages will fail because the backend cannot deserialize the query parameter.

Suggested change
const params = new URLSearchParams({ source_id: sourceId });
const params = new URLSearchParams({ sourceId: sourceId });
Open in Devin Review

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[]>;
}
68 changes: 68 additions & 0 deletions packages/helpchat/src/events.ts
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check if (payload.content) will skip messages with empty string content. Since empty strings are falsy in JavaScript, any AgentMessage with content: "" will be filtered out even though it's a valid message according to the AgentMessage type definition (where content is string, not string | null). This could cause legitimate empty messages from agents to be dropped.

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
if (payload.content) {
onMessage(payload);
}
if (payload.content !== undefined) {
onMessage(payload);
}

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

} catch {
// skip malformed SSE frames
}
}
}
}
22 changes: 22 additions & 0 deletions packages/helpchat/src/index.ts
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";
56 changes: 56 additions & 0 deletions packages/helpchat/src/types.ts
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;
}
13 changes: 13 additions & 0 deletions packages/helpchat/tsconfig.json
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
}
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.