Skip to content
64 changes: 64 additions & 0 deletions packages/api/src/ApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { RocketChatAuth } from "@embeddedchat/auth";

export class RCApiError extends Error {
constructor(public status: number, public body: unknown) {
super(`RC API Error ${status}`);
this.name = "RCApiError";
}
}

export class ApiClient {
private host: string;
private auth: RocketChatAuth;

constructor(host: string, auth: RocketChatAuth) {
this.host = host;
this.auth = auth;
}

private async buildHeaders(): Promise<Record<string, string>> {
const user = await this.auth.getCurrentUser();
return {
"Content-Type": "application/json",
"X-Auth-Token": user?.authToken ?? "",
"X-User-Id": user?.userId ?? "",
};
}

async get<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${this.host}/api/v1/${endpoint}`);
if (params) {
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
}
try {
const res = await fetch(url.toString(), {
method: "GET",
headers: await this.buildHeaders(),
});
if (!res.ok) {
throw new RCApiError(res.status, await res.json());
}
return res.json();
} catch (err) {
if (err instanceof RCApiError) throw err;
throw new Error(`Network error on GET ${endpoint}: ${err}`);
}
}

async post<T>(endpoint: string, body?: object): Promise<T> {
try {
const res = await fetch(`${this.host}/api/v1/${endpoint}`, {
method: "POST",
headers: await this.buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
throw new RCApiError(res.status, await res.json());
}
return res.json();
} catch (err) {
if (err instanceof RCApiError) throw err;
throw new Error(`Network error on POST ${endpoint}: ${err}`);
}
}
}
65 changes: 7 additions & 58 deletions packages/api/src/EmbeddedChatApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Rocketchat } from "@rocket.chat/sdk";
import cloneArray from "./cloneArray";
import { ROCKETCHAT_APP_ID } from "./utils/constants";
import { ApiClient, RCApiError } from "./ApiClient";
import {
IRocketChatAuthOptions,
RocketChatAuth,
Expand All @@ -20,6 +21,7 @@ export default class EmbeddedChatApi {
onUiInteractionCallbacks: ((data: any) => void)[];
typingUsers: string[];
auth: RocketChatAuth;
apiClient: ApiClient;

constructor(
host: string,
Expand All @@ -46,6 +48,7 @@ export default class EmbeddedChatApi {
getToken,
saveToken,
});
this.apiClient = new ApiClient(this.host, this.auth);
}

setAuth(auth: RocketChatAuth) {
Expand Down Expand Up @@ -472,23 +475,7 @@ export default class EmbeddedChatApi {
}

async channelInfo() {
try {
const { userId, authToken } = (await this.auth.getCurrentUser()) || {};
const response = await fetch(
`${this.host}/api/v1/rooms.info?roomId=${this.rid}`,
{
headers: {
"Content-Type": "application/json",
"X-Auth-Token": authToken,
"X-User-Id": userId,
},
method: "GET",
}
);
return await response.json();
} catch (err) {
console.error(err);
}
return this.apiClient.get("rooms.info", { roomId: this.rid });
}

async getRoomInfo() {
Expand Down Expand Up @@ -712,39 +699,11 @@ export default class EmbeddedChatApi {
if (threadId) {
messageObj.tmid = threadId;
}
try {
const { userId, authToken } = (await this.auth.getCurrentUser()) || {};
const response = await fetch(`${this.host}/api/v1/chat.sendMessage`, {
body: JSON.stringify({ message: messageObj }),
headers: {
"Content-Type": "application/json",
"X-Auth-Token": authToken,
"X-User-Id": userId,
},
method: "POST",
});
return await response.json();
} catch (err) {
console.error(err);
}
return this.apiClient.post("chat.sendMessage", { message: messageObj });
}

async deleteMessage(msgId: string) {
try {
const { userId, authToken } = (await this.auth.getCurrentUser()) || {};
const response = await fetch(`${this.host}/api/v1/chat.delete`, {
body: JSON.stringify({ roomId: this.rid, msgId }),
headers: {
"Content-Type": "application/json",
"X-Auth-Token": authToken,
"X-User-Id": userId,
},
method: "POST",
});
return await response.json();
} catch (err) {
console.error(err);
}
return this.apiClient.post("chat.delete", { roomId: this.rid, msgId });
}

async updateMessage(msgId: string, text: string) {
Expand Down Expand Up @@ -1166,17 +1125,7 @@ export default class EmbeddedChatApi {
}

async getCommandsList() {
const { userId, authToken } = (await this.auth.getCurrentUser()) || {};
const response = await fetch(`${this.host}/api/v1/commands.list`, {
headers: {
"Content-Type": "application/json",
"X-Auth-Token": authToken,
"X-User-Id": userId,
},
method: "GET",
});
const data = await response.json();
return data;
return this.apiClient.get("commands.list");
}

async execCommand({
Expand Down
28 changes: 28 additions & 0 deletions packages/api/src/ai/IAIAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type RCMessage = {
_id: string;
msg: string;
u: {
_id: string;
username: string;
name?: string;
};
ts: string | number;
};

export interface IAIAdapter {
/**
* Given the recent message context in a room,
* return 2-3 suggested reply strings.
*/
suggestReply(context: RCMessage[]): Promise<string[]>;

/**
* Given a thread's messages, return a short human-readable summary.
*/
summarizeThread(messages: RCMessage[]): Promise<string>;

/**
* Optional: check if a message should be flagged before sending.
*/
moderateMessage?(text: string): Promise<{ flagged: boolean; reason?: string }>;
}
27 changes: 27 additions & 0 deletions packages/api/src/ai/MockAIAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { IAIAdapter, RCMessage } from "./IAIAdapter";

export class MockAIAdapter implements IAIAdapter {
async suggestReply(context: RCMessage[]): Promise<string[]> {
const lastMsg = context[context.length - 1]?.msg ?? "";
// Simple rule-based mock — shows the shape works
if (lastMsg.includes("?")) {
return [
"I'll look into this.",
"Can you share more details?",
"Sure, let me check.",
];
}
return ["Got it, thanks!", "Understood.", "Will do."];
}

async summarizeThread(messages: RCMessage[]): Promise<string> {
return `Thread of ${messages.length} messages. Started by @${messages[0]?.u?.username ?? "unknown"}: "${messages[0]?.msg?.slice(0, 60)}..."`;
}

async moderateMessage(
text: string
): Promise<{ flagged: boolean; reason?: string }> {
// Mock: flag nothing
return { flagged: false };
}
}
3 changes: 3 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export { default as EmbeddedChatApi } from "./EmbeddedChatApi";
export { ApiClient, RCApiError } from "./ApiClient";
export type { IAIAdapter, RCMessage } from "./ai/IAIAdapter";
export { MockAIAdapter } from "./ai/MockAIAdapter";
1 change: 1 addition & 0 deletions packages/react/src/context/RCInstance.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const RCInstanceProvider = RCContext.Provider;
* @typedef {Object} RCContext
* @property {import('@embeddedchat/api').EmbeddedChatApi} RCInstance
* @property {ECOptions} ECOptions
* @property {import('@embeddedchat/api').IAIAdapter | null} aiAdapter
* @returns {RCContext}
*/
export const useRCContext = () => useContext(RCContext);
Expand Down
50 changes: 50 additions & 0 deletions packages/react/src/hooks/useAIAdapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useState, useCallback } from 'react';
import { useRCContext } from '../context/RCInstance';

export const useAIAdapter = () => {
const { aiAdapter } = useRCContext();
const [suggestions, setSuggestions] = useState([]);
const [summary, setSummary] = useState('');
const [isLoading, setIsLoading] = useState(false);

const getSuggestions = useCallback(
async (messages) => {
if (!aiAdapter) return;
setIsLoading(true);
try {
const result = await aiAdapter.suggestReply(messages);
setSuggestions(result);
} catch (e) {
console.error('AI suggestReply failed:', e);
} finally {
setIsLoading(false);
}
},
[aiAdapter]
);

const getThreadSummary = useCallback(
async (messages) => {
if (!aiAdapter) return;
setIsLoading(true);
try {
const result = await aiAdapter.summarizeThread(messages);
setSummary(result);
} catch (e) {
console.error('AI summarizeThread failed:', e);
} finally {
setIsLoading(false);
}
},
[aiAdapter]
);

return {
isAIEnabled: !!aiAdapter,
suggestions,
summary,
isLoading,
getSuggestions,
getThreadSummary,
};
};
2 changes: 2 additions & 0 deletions packages/react/src/stories/EmbeddedChat.stories.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MockAIAdapter } from '@embeddedchat/api';
import { EmbeddedChat } from '..';

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
Expand All @@ -22,5 +23,6 @@ export const Simple = {
flow: 'PASSWORD',
},
dark: false,
aiAdapter: new MockAIAdapter(),
},
};
2 changes: 2 additions & 0 deletions packages/react/src/stories/EmbeddedChatAuthToken.stories.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MockAIAdapter } from '@embeddedchat/api';
import { EmbeddedChat } from '..';

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
Expand All @@ -24,5 +25,6 @@ export const WithAuthToken = {
resume: 'resume_token',
},
},
aiAdapter: new MockAIAdapter(),
},
};
2 changes: 2 additions & 0 deletions packages/react/src/stories/EmbeddedChatSecureAuth.stories.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MockAIAdapter } from '@embeddedchat/api';
import { EmbeddedChat } from '..';

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
Expand All @@ -23,5 +24,6 @@ export const Secure_Auth = {
},
secure: true,
dark: false,
aiAdapter: new MockAIAdapter(),
},
};
2 changes: 2 additions & 0 deletions packages/react/src/stories/EmbeddedChatWithOAuth.stories.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MockAIAdapter } from '@embeddedchat/api';
import { EmbeddedChat } from '..';

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
Expand All @@ -21,5 +22,6 @@ export const WithOAuth = {
auth: {
flow: 'OAUTH',
},
aiAdapter: new MockAIAdapter(),
},
};
2 changes: 2 additions & 0 deletions packages/react/src/stories/EmbeddedChatWithRemote.stories.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MockAIAdapter } from '@embeddedchat/api';
import { EmbeddedChat } from '..';

export default {
Expand All @@ -24,5 +25,6 @@ export const With_Remote_Opt = {
flow: 'PASSWORD',
},
dark: false,
aiAdapter: new MockAIAdapter(),
},
};
Loading