@pmate/chat-sdk 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/AttachmentPreview.js +20 -4
- package/lib/components/ChatInputDesktop.js +149 -43
- package/lib/components/ChatInputMobile.js +119 -44
- package/lib/components/HoldToTalkButton.js +10 -9
- package/lib/components/ImageTrigger.d.ts +1 -1
- package/lib/components/ImageTrigger.js +1 -1
- package/lib/components/icons.d.ts +3 -0
- package/lib/components/icons.js +13 -4
- package/lib/hooks/useAttachmentState.d.ts +3 -1
- package/lib/hooks/useVoiceInputController.js +21 -2
- package/lib/index.d.ts +4 -0
- package/lib/index.js +4 -0
- package/lib/runtime/botAuth.d.ts +32 -0
- package/lib/runtime/botAuth.js +71 -0
- package/lib/runtime/botKey.d.ts +14 -0
- package/lib/runtime/botKey.js +25 -0
- package/lib/runtime/chatClient.d.ts +62 -0
- package/lib/runtime/chatClient.js +209 -0
- package/lib/runtime/index.d.ts +5 -0
- package/lib/runtime/index.js +5 -0
- package/lib/runtime/protocol.d.ts +83 -0
- package/lib/runtime/protocol.js +113 -0
- package/lib/runtime/thread.d.ts +3 -0
- package/lib/runtime/thread.js +18 -0
- package/lib/tokens/chatInputThemes.js +20 -20
- package/lib/tokens/defaultTokens.js +17 -17
- package/lib/types/attachment.d.ts +1 -1
- package/lib/types/voice.d.ts +1 -0
- package/package.json +7 -1
- package/dist/assets/ChatInput.stories-CSt4WoEv.js +0 -1702
- package/dist/assets/Color-AVL7NMMY-BspvszXC.js +0 -1
- package/dist/assets/DocsRenderer-PQXLIZUC-BM8CapQZ.js +0 -1243
- package/dist/assets/client-Bw6ZM57k.js +0 -9
- package/dist/assets/iframe-Dci6y3pc.js +0 -1071
- package/dist/assets/iframe-DnNTepBm.css +0 -1
- package/dist/assets/index-DQ_t8Ou6.js +0 -1
- package/dist/assets/preload-helper-PPVm8Dsz.js +0 -1
- package/dist/assets/react-18-3UzO1Bv7.js +0 -1
- package/dist/favicon-wrapper.svg +0 -46
- package/dist/favicon.svg +0 -1
- package/dist/iframe.html +0 -687
- package/dist/index.html +0 -148
- package/dist/index.json +0 -1
- package/dist/nunito-sans-bold-italic.woff2 +0 -0
- package/dist/nunito-sans-bold.woff2 +0 -0
- package/dist/nunito-sans-italic.woff2 +0 -0
- package/dist/nunito-sans-regular.woff2 +0 -0
- package/dist/project.json +0 -1
- package/dist/sb-addons/docs-1/manager-bundle.js +0 -151
- package/dist/sb-addons/docs-1/manager-bundle.js.LEGAL.txt +0 -0
- package/dist/sb-addons/storybook-core-server-presets-0/common-manager-bundle.js +0 -971
- package/dist/sb-addons/storybook-core-server-presets-0/common-manager-bundle.js.LEGAL.txt +0 -0
- package/dist/sb-common-assets/favicon-wrapper.svg +0 -46
- package/dist/sb-common-assets/favicon.svg +0 -1
- package/dist/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
- package/dist/sb-common-assets/nunito-sans-bold.woff2 +0 -0
- package/dist/sb-common-assets/nunito-sans-italic.woff2 +0 -0
- package/dist/sb-common-assets/nunito-sans-regular.woff2 +0 -0
- package/dist/sb-manager/globals-module-info.js +0 -799
- package/dist/sb-manager/globals-runtime.js +0 -69791
- package/dist/sb-manager/globals.js +0 -34
- package/dist/sb-manager/runtime.js +0 -13198
- package/dist/vite-inject-mocker-entry.js +0 -2
|
@@ -7,6 +7,7 @@ import { MicEvents, MicStateManager } from "../voice/MicStateManager";
|
|
|
7
7
|
const DEFAULT_WAVEFORM = [
|
|
8
8
|
0.18, 0.28, 0.42, 0.58, 0.74, 0.9, 0.74, 0.58, 0.42, 0.28, 0.18,
|
|
9
9
|
];
|
|
10
|
+
const DEFAULT_TRANSCRIBE_TIMEOUT_MS = 30_000;
|
|
10
11
|
export function useVoiceInputController(config) {
|
|
11
12
|
const managerRef = useRef(null);
|
|
12
13
|
const stopActionRef = useRef("send");
|
|
@@ -68,10 +69,11 @@ export function useVoiceInputController(config) {
|
|
|
68
69
|
}, []);
|
|
69
70
|
async function transcribeBlob(blob) {
|
|
70
71
|
try {
|
|
72
|
+
const transcribeTimeoutMs = configRef.current?.transcribeTimeoutMs ?? DEFAULT_TRANSCRIBE_TIMEOUT_MS;
|
|
71
73
|
const nextTranscript = configRef.current?.asrClient
|
|
72
|
-
? (await configRef.current.asrClient.transcribe(blob, {
|
|
74
|
+
? (await withTimeout(configRef.current.asrClient.transcribe(blob, {
|
|
73
75
|
lang: configRef.current.lang,
|
|
74
|
-
})).text.trim()
|
|
76
|
+
}), transcribeTimeoutMs, "Voice transcription timed out. Please try again.")).text.trim()
|
|
75
77
|
: (configRef.current?.simulatedTranscript ?? "hello from transcript").trim();
|
|
76
78
|
if (!nextTranscript) {
|
|
77
79
|
setStatus("error");
|
|
@@ -131,3 +133,20 @@ export function useVoiceInputController(config) {
|
|
|
131
133
|
stopRecording,
|
|
132
134
|
};
|
|
133
135
|
}
|
|
136
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
137
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
138
|
+
return promise;
|
|
139
|
+
}
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timeout = globalThis.setTimeout(() => {
|
|
142
|
+
reject(new Error(message));
|
|
143
|
+
}, timeoutMs);
|
|
144
|
+
promise.then((value) => {
|
|
145
|
+
globalThis.clearTimeout(timeout);
|
|
146
|
+
resolve(value);
|
|
147
|
+
}, (error) => {
|
|
148
|
+
globalThis.clearTimeout(timeout);
|
|
149
|
+
reject(error);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ export * from "./hooks/useTranscriptComposer";
|
|
|
16
16
|
export * from "./hooks/useVoiceInputController";
|
|
17
17
|
export * from "./services/agentSdkASRClient";
|
|
18
18
|
export * from "./services/authTokenResolver";
|
|
19
|
+
export * from "./runtime/botAuth";
|
|
20
|
+
export * from "./runtime/botKey";
|
|
21
|
+
export * from "./runtime/chatClient";
|
|
22
|
+
export * from "./runtime/thread";
|
|
19
23
|
export * from "./tokens/defaultTokens";
|
|
20
24
|
export * from "./tokens/chatInputThemes";
|
|
21
25
|
export * from "./types/attachment";
|
package/lib/index.js
CHANGED
|
@@ -16,6 +16,10 @@ export * from "./hooks/useTranscriptComposer";
|
|
|
16
16
|
export * from "./hooks/useVoiceInputController";
|
|
17
17
|
export * from "./services/agentSdkASRClient";
|
|
18
18
|
export * from "./services/authTokenResolver";
|
|
19
|
+
export * from "./runtime/botAuth";
|
|
20
|
+
export * from "./runtime/botKey";
|
|
21
|
+
export * from "./runtime/chatClient";
|
|
22
|
+
export * from "./runtime/thread";
|
|
19
23
|
export * from "./tokens/defaultTokens";
|
|
20
24
|
export * from "./tokens/chatInputThemes";
|
|
21
25
|
export * from "./types/attachment";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type PmateBotKeyFile } from "./botKey.js";
|
|
2
|
+
export type BotLoginChallenge = {
|
|
3
|
+
id: string;
|
|
4
|
+
app: string;
|
|
5
|
+
name: string;
|
|
6
|
+
accountId: string;
|
|
7
|
+
credentialId: string;
|
|
8
|
+
challenge: string;
|
|
9
|
+
issuedAt: string;
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
};
|
|
12
|
+
export type BotLoginSession = {
|
|
13
|
+
token: string;
|
|
14
|
+
identity?: {
|
|
15
|
+
accountId?: string;
|
|
16
|
+
app?: string;
|
|
17
|
+
kind?: string;
|
|
18
|
+
};
|
|
19
|
+
session?: {
|
|
20
|
+
identity?: {
|
|
21
|
+
accountId?: string;
|
|
22
|
+
app?: string;
|
|
23
|
+
kind?: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export declare function loginPmateBot(input: {
|
|
28
|
+
botKey: PmateBotKeyFile;
|
|
29
|
+
authBaseUrl?: string;
|
|
30
|
+
fetchImpl?: typeof fetch;
|
|
31
|
+
}): Promise<BotLoginSession>;
|
|
32
|
+
export declare function canonicalBotChallenge(challenge: BotLoginChallenge): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { botCredentialId } from "./botKey.js";
|
|
2
|
+
const DEFAULT_AUTH_API_BASE_URL = "https://auth-api-v2.pmate.chat";
|
|
3
|
+
export async function loginPmateBot(input) {
|
|
4
|
+
const fetcher = input.fetchImpl ?? fetch;
|
|
5
|
+
const baseUrl = (input.authBaseUrl ?? DEFAULT_AUTH_API_BASE_URL).replace(/\/+$/, "");
|
|
6
|
+
const credentialId = botCredentialId(input.botKey);
|
|
7
|
+
const challenge = await post(fetcher, baseUrl, "/accounts/bots/session/challenge", {
|
|
8
|
+
app: input.botKey.app,
|
|
9
|
+
name: input.botKey.name,
|
|
10
|
+
credentialId,
|
|
11
|
+
});
|
|
12
|
+
const signature = await signBotChallenge(challenge, input.botKey.privateKey);
|
|
13
|
+
return post(fetcher, baseUrl, "/accounts/bots/session", {
|
|
14
|
+
app: challenge.app,
|
|
15
|
+
name: challenge.name,
|
|
16
|
+
credentialId: challenge.credentialId,
|
|
17
|
+
challengeId: challenge.id,
|
|
18
|
+
challenge: challenge.challenge,
|
|
19
|
+
signature,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function canonicalBotChallenge(challenge) {
|
|
23
|
+
return JSON.stringify({
|
|
24
|
+
id: challenge.id,
|
|
25
|
+
app: challenge.app,
|
|
26
|
+
name: challenge.name,
|
|
27
|
+
accountId: challenge.accountId,
|
|
28
|
+
credentialId: challenge.credentialId,
|
|
29
|
+
challenge: challenge.challenge,
|
|
30
|
+
issuedAt: challenge.issuedAt,
|
|
31
|
+
expiresAt: challenge.expiresAt,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function post(fetcher, baseUrl, path, body) {
|
|
35
|
+
const response = await fetcher(`${baseUrl}${path}?app=${encodeURIComponent(String(body.app ?? ""))}`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
const json = (await response.json());
|
|
41
|
+
if (!response.ok || !json.success || !json.data) {
|
|
42
|
+
throw new Error(json.message || `Bot auth request failed: ${path}`);
|
|
43
|
+
}
|
|
44
|
+
return json.data;
|
|
45
|
+
}
|
|
46
|
+
async function signBotChallenge(challenge, privateKey) {
|
|
47
|
+
const subtle = globalThis.crypto?.subtle;
|
|
48
|
+
if (!subtle) {
|
|
49
|
+
throw new Error("WebCrypto is required for PMate bot login");
|
|
50
|
+
}
|
|
51
|
+
const key = await subtle.importKey("pkcs8", base64UrlToBytes(privateKey), "Ed25519", false, ["sign"]);
|
|
52
|
+
const signature = await subtle.sign("Ed25519", key, new TextEncoder().encode(canonicalBotChallenge(challenge)));
|
|
53
|
+
return bytesToBase64Url(new Uint8Array(signature));
|
|
54
|
+
}
|
|
55
|
+
function base64UrlToBytes(value) {
|
|
56
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
57
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
58
|
+
const binary = atob(padded);
|
|
59
|
+
const bytes = new Uint8Array(binary.length);
|
|
60
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
61
|
+
bytes[index] = binary.charCodeAt(index);
|
|
62
|
+
}
|
|
63
|
+
return bytes;
|
|
64
|
+
}
|
|
65
|
+
function bytesToBase64Url(bytes) {
|
|
66
|
+
let binary = "";
|
|
67
|
+
for (const byte of bytes) {
|
|
68
|
+
binary += String.fromCharCode(byte);
|
|
69
|
+
}
|
|
70
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type PmateBotKeyFile = {
|
|
2
|
+
type: "pmate-bot-key";
|
|
3
|
+
version: 1;
|
|
4
|
+
algorithm: "ed25519";
|
|
5
|
+
app: string;
|
|
6
|
+
name: string;
|
|
7
|
+
publicKey: string;
|
|
8
|
+
privateKey: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
credentialId?: string;
|
|
11
|
+
publicKeyId?: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function parsePmateBotKeyJson(input: string | unknown): PmateBotKeyFile;
|
|
14
|
+
export declare function botCredentialId(key: PmateBotKeyFile): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function parsePmateBotKeyJson(input) {
|
|
2
|
+
const value = typeof input === "string" ? JSON.parse(input) : input;
|
|
3
|
+
if (!value || typeof value !== "object") {
|
|
4
|
+
throw new Error("Invalid PMate bot key JSON");
|
|
5
|
+
}
|
|
6
|
+
const candidate = value;
|
|
7
|
+
if (candidate.type !== "pmate-bot-key") {
|
|
8
|
+
throw new Error('Invalid PMate bot key JSON: expected type "pmate-bot-key"');
|
|
9
|
+
}
|
|
10
|
+
if (candidate.version !== 1) {
|
|
11
|
+
throw new Error("Invalid PMate bot key JSON: expected version 1");
|
|
12
|
+
}
|
|
13
|
+
if (candidate.algorithm !== "ed25519") {
|
|
14
|
+
throw new Error('Invalid PMate bot key JSON: expected algorithm "ed25519"');
|
|
15
|
+
}
|
|
16
|
+
for (const key of ["app", "name", "publicKey", "privateKey", "createdAt"]) {
|
|
17
|
+
if (!candidate[key]?.trim()) {
|
|
18
|
+
throw new Error(`Invalid PMate bot key JSON: missing ${key}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return candidate;
|
|
22
|
+
}
|
|
23
|
+
export function botCredentialId(key) {
|
|
24
|
+
return key.credentialId || key.publicKeyId || key.publicKey;
|
|
25
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type BotLoginSession } from "./botAuth.js";
|
|
2
|
+
import { type PmateBotKeyFile } from "./botKey.js";
|
|
3
|
+
import { type ChatBody, type ChatRuntimeMessage, type RealtimeClient } from "./protocol.js";
|
|
4
|
+
export type ChatClientOptions = {
|
|
5
|
+
appId: string;
|
|
6
|
+
botKey?: PmateBotKeyFile | string | unknown;
|
|
7
|
+
session?: BotLoginSession;
|
|
8
|
+
hubUrl?: string;
|
|
9
|
+
authBaseUrl?: string;
|
|
10
|
+
realtimeClient?: RealtimeClient;
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
ackTimeoutMs?: number;
|
|
13
|
+
};
|
|
14
|
+
export declare class ChatClient {
|
|
15
|
+
private readonly options;
|
|
16
|
+
private client;
|
|
17
|
+
private session;
|
|
18
|
+
private botKey;
|
|
19
|
+
private readonly pendingAcks;
|
|
20
|
+
private readonly messageHandlers;
|
|
21
|
+
constructor(options: ChatClientOptions);
|
|
22
|
+
connect(): Promise<this>;
|
|
23
|
+
close(): void;
|
|
24
|
+
watchMessages(handler: (message: ChatRuntimeMessage) => void): () => boolean;
|
|
25
|
+
requestFriend(input: {
|
|
26
|
+
to: string;
|
|
27
|
+
message?: string;
|
|
28
|
+
}): Promise<ChatRuntimeMessage>;
|
|
29
|
+
acceptFriend(input: {
|
|
30
|
+
to: string;
|
|
31
|
+
requestId?: string;
|
|
32
|
+
message?: string;
|
|
33
|
+
}): Promise<ChatRuntimeMessage>;
|
|
34
|
+
rejectFriend(input: {
|
|
35
|
+
to: string;
|
|
36
|
+
requestId?: string;
|
|
37
|
+
reason?: string;
|
|
38
|
+
}): Promise<ChatRuntimeMessage>;
|
|
39
|
+
sendDm(input: {
|
|
40
|
+
to: string;
|
|
41
|
+
text: string;
|
|
42
|
+
}): Promise<ChatRuntimeMessage>;
|
|
43
|
+
createGroup(input: {
|
|
44
|
+
groupId: string;
|
|
45
|
+
members: string[];
|
|
46
|
+
title?: string;
|
|
47
|
+
}): Promise<ChatRuntimeMessage>;
|
|
48
|
+
sendGroup(input: {
|
|
49
|
+
groupId: string;
|
|
50
|
+
text: string;
|
|
51
|
+
recipients?: string[];
|
|
52
|
+
}): Promise<ChatRuntimeMessage>;
|
|
53
|
+
sendChat(to: string, body: ChatBody, recipients?: string[]): Promise<ChatRuntimeMessage>;
|
|
54
|
+
private sendContactAction;
|
|
55
|
+
private sendAuth;
|
|
56
|
+
private sendAndWaitForAck;
|
|
57
|
+
private handleIncoming;
|
|
58
|
+
private waitUntilConnected;
|
|
59
|
+
private accountId;
|
|
60
|
+
private requireClient;
|
|
61
|
+
}
|
|
62
|
+
export declare function createChatClient(options: ChatClientOptions): ChatClient;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { loginPmateBot } from "./botAuth.js";
|
|
2
|
+
import { parsePmateBotKeyJson } from "./botKey.js";
|
|
3
|
+
import { ChatMsgKind, ChatMsgOp, ChatRealtimeEvents, RuntimeWebsocketClient, createRuntimeMsg, } from "./protocol.js";
|
|
4
|
+
const DEFAULT_HUB_URL = "wss://hub.pmate.chat";
|
|
5
|
+
const DEFAULT_ACK_TIMEOUT_MS = 5_000;
|
|
6
|
+
export class ChatClient {
|
|
7
|
+
options;
|
|
8
|
+
client = null;
|
|
9
|
+
session = null;
|
|
10
|
+
botKey = null;
|
|
11
|
+
pendingAcks = new Map();
|
|
12
|
+
messageHandlers = new Set();
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
if (options.botKey) {
|
|
16
|
+
this.botKey = parsePmateBotKeyJson(options.botKey);
|
|
17
|
+
}
|
|
18
|
+
this.session = options.session ?? null;
|
|
19
|
+
this.client = options.realtimeClient ?? null;
|
|
20
|
+
}
|
|
21
|
+
async connect() {
|
|
22
|
+
if (!this.session) {
|
|
23
|
+
if (!this.botKey) {
|
|
24
|
+
throw new Error("ChatClient requires either session or PMate bot key JSON");
|
|
25
|
+
}
|
|
26
|
+
this.session = await loginPmateBot({
|
|
27
|
+
botKey: this.botKey,
|
|
28
|
+
authBaseUrl: this.options.authBaseUrl,
|
|
29
|
+
fetchImpl: this.options.fetchImpl,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const accountId = this.accountId();
|
|
33
|
+
if (!this.client) {
|
|
34
|
+
const hubUrl = normalizeWsUrl(this.options.hubUrl ?? DEFAULT_HUB_URL, accountId);
|
|
35
|
+
this.client = new RuntimeWebsocketClient(hubUrl);
|
|
36
|
+
}
|
|
37
|
+
this.client.on(ChatRealtimeEvents.Message, (message) => {
|
|
38
|
+
this.handleIncoming(message);
|
|
39
|
+
});
|
|
40
|
+
await this.waitUntilConnected();
|
|
41
|
+
await this.sendAuth();
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
close() {
|
|
45
|
+
this.client?.close();
|
|
46
|
+
this.client = null;
|
|
47
|
+
for (const pending of this.pendingAcks.values()) {
|
|
48
|
+
pending.reject(new Error("ChatClient closed"));
|
|
49
|
+
clearTimeout(pending.timer);
|
|
50
|
+
}
|
|
51
|
+
this.pendingAcks.clear();
|
|
52
|
+
}
|
|
53
|
+
watchMessages(handler) {
|
|
54
|
+
this.messageHandlers.add(handler);
|
|
55
|
+
return () => this.messageHandlers.delete(handler);
|
|
56
|
+
}
|
|
57
|
+
async requestFriend(input) {
|
|
58
|
+
return this.sendContactAction(input.to, "friendRequest", {
|
|
59
|
+
message: input.message,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async acceptFriend(input) {
|
|
63
|
+
return this.sendContactAction(input.to, "friendAccept", {
|
|
64
|
+
requestId: input.requestId,
|
|
65
|
+
message: input.message,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async rejectFriend(input) {
|
|
69
|
+
return this.sendContactAction(input.to, "friendReject", {
|
|
70
|
+
requestId: input.requestId,
|
|
71
|
+
reason: input.reason,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async sendDm(input) {
|
|
75
|
+
return this.sendChat(input.to, {
|
|
76
|
+
kind: "message",
|
|
77
|
+
threadKind: "dm",
|
|
78
|
+
contentKind: "text",
|
|
79
|
+
content: { appId: this.options.appId, text: input.text },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async createGroup(input) {
|
|
83
|
+
return this.sendChat(input.groupId, {
|
|
84
|
+
kind: "group",
|
|
85
|
+
threadKind: "group",
|
|
86
|
+
action: "groupCreate",
|
|
87
|
+
payload: { appId: this.options.appId, groupId: input.groupId, members: input.members, title: input.title },
|
|
88
|
+
}, input.members);
|
|
89
|
+
}
|
|
90
|
+
async sendGroup(input) {
|
|
91
|
+
return this.sendChat(input.groupId, {
|
|
92
|
+
kind: "message",
|
|
93
|
+
threadKind: "group",
|
|
94
|
+
contentKind: "text",
|
|
95
|
+
content: { appId: this.options.appId, groupId: input.groupId, text: input.text },
|
|
96
|
+
}, input.recipients);
|
|
97
|
+
}
|
|
98
|
+
async sendChat(to, body, recipients) {
|
|
99
|
+
const msg = createRuntimeMsg(this.accountId(), to, ChatMsgOp.CHAT, body, ChatMsgKind.DM);
|
|
100
|
+
if (recipients) {
|
|
101
|
+
msg.recipients = recipients;
|
|
102
|
+
}
|
|
103
|
+
await this.sendAndWaitForAck(msg);
|
|
104
|
+
return msg;
|
|
105
|
+
}
|
|
106
|
+
async sendContactAction(to, action, payload) {
|
|
107
|
+
return this.sendChat(to, {
|
|
108
|
+
kind: "contact",
|
|
109
|
+
threadKind: "dm",
|
|
110
|
+
action,
|
|
111
|
+
payload: cleanObject({ appId: this.options.appId, ...payload }),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async sendAuth() {
|
|
115
|
+
const token = this.session?.token;
|
|
116
|
+
if (!token) {
|
|
117
|
+
throw new Error("Missing PMate session token");
|
|
118
|
+
}
|
|
119
|
+
const auth = createRuntimeMsg(this.accountId(), "@hub", ChatMsgOp.AUTH, {
|
|
120
|
+
token,
|
|
121
|
+
as: "user",
|
|
122
|
+
}, ChatMsgKind.DM);
|
|
123
|
+
await this.sendAndWaitForAck(auth);
|
|
124
|
+
}
|
|
125
|
+
async sendAndWaitForAck(msg) {
|
|
126
|
+
const client = this.requireClient();
|
|
127
|
+
const ack = new Promise((resolve, reject) => {
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
this.pendingAcks.delete(msg.hash);
|
|
130
|
+
reject(new Error(`Timed out waiting for ack of ${msg.hash}`));
|
|
131
|
+
}, this.options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS);
|
|
132
|
+
this.pendingAcks.set(msg.hash, { resolve, reject, timer });
|
|
133
|
+
});
|
|
134
|
+
await client.sendMsg(msg);
|
|
135
|
+
return ack;
|
|
136
|
+
}
|
|
137
|
+
handleIncoming(message) {
|
|
138
|
+
if (message.opcode === ChatMsgOp.ACK) {
|
|
139
|
+
const ack = message.body;
|
|
140
|
+
const pending = this.pendingAcks.get(ack.ackOf);
|
|
141
|
+
if (pending) {
|
|
142
|
+
this.pendingAcks.delete(ack.ackOf);
|
|
143
|
+
clearTimeout(pending.timer);
|
|
144
|
+
if (ack.status === "ok") {
|
|
145
|
+
pending.resolve(ack);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
pending.reject(new Error(ack.message || ack.code || "Ack failed"));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (message.opcode === ChatMsgOp.CHAT) {
|
|
154
|
+
for (const handler of this.messageHandlers) {
|
|
155
|
+
handler(message);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
waitUntilConnected() {
|
|
160
|
+
const client = this.requireClient();
|
|
161
|
+
if (client.isConnected()) {
|
|
162
|
+
return Promise.resolve();
|
|
163
|
+
}
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const offConnected = client.on(ChatRealtimeEvents.Connected, (connected) => {
|
|
166
|
+
if (connected) {
|
|
167
|
+
offConnected();
|
|
168
|
+
offError();
|
|
169
|
+
resolve();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
const offError = client.on(ChatRealtimeEvents.Error, (error) => {
|
|
173
|
+
offConnected();
|
|
174
|
+
offError();
|
|
175
|
+
reject(error instanceof Error ? error : new Error("WebSocket connection failed"));
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
accountId() {
|
|
180
|
+
const accountId = this.session?.session?.identity?.accountId ||
|
|
181
|
+
this.session?.identity?.accountId ||
|
|
182
|
+
this.botKey?.name;
|
|
183
|
+
if (!accountId) {
|
|
184
|
+
throw new Error("Missing account id");
|
|
185
|
+
}
|
|
186
|
+
return accountId;
|
|
187
|
+
}
|
|
188
|
+
requireClient() {
|
|
189
|
+
if (!this.client) {
|
|
190
|
+
throw new Error("ChatClient is not connected");
|
|
191
|
+
}
|
|
192
|
+
return this.client;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export function createChatClient(options) {
|
|
196
|
+
return new ChatClient(options);
|
|
197
|
+
}
|
|
198
|
+
function normalizeWsUrl(baseUrl, userId) {
|
|
199
|
+
const url = new URL(baseUrl);
|
|
200
|
+
if (url.protocol === "http:")
|
|
201
|
+
url.protocol = "ws:";
|
|
202
|
+
if (url.protocol === "https:")
|
|
203
|
+
url.protocol = "wss:";
|
|
204
|
+
url.searchParams.set("userId", userId);
|
|
205
|
+
return url.toString();
|
|
206
|
+
}
|
|
207
|
+
function cleanObject(input) {
|
|
208
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined && value !== null));
|
|
209
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export declare const ChatMsgOp: {
|
|
2
|
+
readonly CHAT: 1;
|
|
3
|
+
readonly ACK: 3;
|
|
4
|
+
readonly AUTH: 4;
|
|
5
|
+
};
|
|
6
|
+
export declare const ChatMsgKind: {
|
|
7
|
+
readonly DM: 1;
|
|
8
|
+
readonly GROUP: 2;
|
|
9
|
+
};
|
|
10
|
+
export declare const ChatRealtimeEvents: {
|
|
11
|
+
readonly Connected: "connected";
|
|
12
|
+
readonly Error: "error";
|
|
13
|
+
readonly Message: "message";
|
|
14
|
+
};
|
|
15
|
+
export type ChatBody = {
|
|
16
|
+
kind: "message";
|
|
17
|
+
threadKind: "dm" | "group";
|
|
18
|
+
contentKind: "text" | "image" | "emoji";
|
|
19
|
+
content: Record<string, unknown>;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "pending";
|
|
22
|
+
threadKind: "dm" | "group";
|
|
23
|
+
pendingId: string;
|
|
24
|
+
expire?: number;
|
|
25
|
+
content?: Record<string, unknown>;
|
|
26
|
+
} | {
|
|
27
|
+
kind: "quote";
|
|
28
|
+
threadKind: "dm" | "group";
|
|
29
|
+
quotedMessageId: string;
|
|
30
|
+
content: Record<string, unknown>;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "contact" | "group";
|
|
33
|
+
threadKind: "dm" | "group";
|
|
34
|
+
action: string;
|
|
35
|
+
payload?: Record<string, unknown>;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "recall" | "edit" | "delete" | "hello";
|
|
38
|
+
threadKind: "dm" | "group";
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
};
|
|
41
|
+
export type AckBody = {
|
|
42
|
+
ackOf: string;
|
|
43
|
+
status: "ok" | "error";
|
|
44
|
+
code?: string;
|
|
45
|
+
message?: string;
|
|
46
|
+
data?: unknown;
|
|
47
|
+
};
|
|
48
|
+
export type ChatRuntimeMessage = {
|
|
49
|
+
hash: string;
|
|
50
|
+
t: number;
|
|
51
|
+
from: string;
|
|
52
|
+
to: string;
|
|
53
|
+
body: unknown;
|
|
54
|
+
kind: number;
|
|
55
|
+
opcode: number;
|
|
56
|
+
pending?: {
|
|
57
|
+
expire: number;
|
|
58
|
+
};
|
|
59
|
+
quote?: string;
|
|
60
|
+
recipients?: string[];
|
|
61
|
+
};
|
|
62
|
+
export interface RealtimeClient {
|
|
63
|
+
on<T>(topic: string, handler: (value: T) => void): () => void;
|
|
64
|
+
isConnected(): boolean;
|
|
65
|
+
close(manual?: boolean): void;
|
|
66
|
+
sendMsg(msg: ChatRuntimeMessage): Promise<void>;
|
|
67
|
+
getConnectionType(): "websocket";
|
|
68
|
+
}
|
|
69
|
+
export declare function createRuntimeMsg(from: string, to: string, opcode: number, body: unknown, kind?: number): ChatRuntimeMessage;
|
|
70
|
+
export declare class RuntimeWebsocketClient implements RealtimeClient {
|
|
71
|
+
private readonly wsUrl;
|
|
72
|
+
private ws;
|
|
73
|
+
private connected;
|
|
74
|
+
private readonly listeners;
|
|
75
|
+
constructor(wsUrl: string);
|
|
76
|
+
on<T>(topic: string, handler: (value: T) => void): () => boolean;
|
|
77
|
+
isConnected(): boolean;
|
|
78
|
+
close(): void;
|
|
79
|
+
sendMsg(msg: ChatRuntimeMessage): Promise<void>;
|
|
80
|
+
getConnectionType(): "websocket";
|
|
81
|
+
private connect;
|
|
82
|
+
private emit;
|
|
83
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { decode, encode } from "@msgpack/msgpack";
|
|
2
|
+
export const ChatMsgOp = {
|
|
3
|
+
CHAT: 1,
|
|
4
|
+
ACK: 3,
|
|
5
|
+
AUTH: 4,
|
|
6
|
+
};
|
|
7
|
+
export const ChatMsgKind = {
|
|
8
|
+
DM: 1,
|
|
9
|
+
GROUP: 2,
|
|
10
|
+
};
|
|
11
|
+
export const ChatRealtimeEvents = {
|
|
12
|
+
Connected: "connected",
|
|
13
|
+
Error: "error",
|
|
14
|
+
Message: "message",
|
|
15
|
+
};
|
|
16
|
+
let msgSeq = 0;
|
|
17
|
+
export function createRuntimeMsg(from, to, opcode, body, kind = ChatMsgKind.DM) {
|
|
18
|
+
const seq = nextMsgSeq();
|
|
19
|
+
const msg = {
|
|
20
|
+
hash: "",
|
|
21
|
+
t: Date.now(),
|
|
22
|
+
from,
|
|
23
|
+
to,
|
|
24
|
+
body,
|
|
25
|
+
kind,
|
|
26
|
+
opcode,
|
|
27
|
+
};
|
|
28
|
+
msg.hash = hashRuntimeMsg(msg, seq);
|
|
29
|
+
return msg;
|
|
30
|
+
}
|
|
31
|
+
export class RuntimeWebsocketClient {
|
|
32
|
+
wsUrl;
|
|
33
|
+
ws = null;
|
|
34
|
+
connected = false;
|
|
35
|
+
listeners = new Map();
|
|
36
|
+
constructor(wsUrl) {
|
|
37
|
+
this.wsUrl = wsUrl;
|
|
38
|
+
this.connect();
|
|
39
|
+
}
|
|
40
|
+
on(topic, handler) {
|
|
41
|
+
const set = this.listeners.get(topic) ?? new Set();
|
|
42
|
+
set.add(handler);
|
|
43
|
+
this.listeners.set(topic, set);
|
|
44
|
+
return () => set.delete(handler);
|
|
45
|
+
}
|
|
46
|
+
isConnected() {
|
|
47
|
+
return this.connected;
|
|
48
|
+
}
|
|
49
|
+
close() {
|
|
50
|
+
this.ws?.close();
|
|
51
|
+
this.ws = null;
|
|
52
|
+
this.connected = false;
|
|
53
|
+
}
|
|
54
|
+
async sendMsg(msg) {
|
|
55
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
56
|
+
throw new Error("Websocket is not connected");
|
|
57
|
+
}
|
|
58
|
+
this.ws.send(encode(msg));
|
|
59
|
+
}
|
|
60
|
+
getConnectionType() {
|
|
61
|
+
return "websocket";
|
|
62
|
+
}
|
|
63
|
+
connect() {
|
|
64
|
+
this.ws = new WebSocket(this.wsUrl);
|
|
65
|
+
this.ws.binaryType = "arraybuffer";
|
|
66
|
+
this.ws.onopen = () => {
|
|
67
|
+
this.connected = true;
|
|
68
|
+
this.emit(ChatRealtimeEvents.Connected, true);
|
|
69
|
+
};
|
|
70
|
+
this.ws.onclose = () => {
|
|
71
|
+
this.connected = false;
|
|
72
|
+
this.emit(ChatRealtimeEvents.Connected, false);
|
|
73
|
+
};
|
|
74
|
+
this.ws.onerror = (event) => {
|
|
75
|
+
this.emit(ChatRealtimeEvents.Error, event);
|
|
76
|
+
};
|
|
77
|
+
this.ws.onmessage = (event) => {
|
|
78
|
+
try {
|
|
79
|
+
this.emit(ChatRealtimeEvents.Message, decodeWireMessage(event.data));
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
this.emit(ChatRealtimeEvents.Error, error);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
emit(topic, value) {
|
|
87
|
+
for (const handler of this.listeners.get(topic) ?? []) {
|
|
88
|
+
handler(value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function decodeWireMessage(data) {
|
|
93
|
+
if (typeof data === "string") {
|
|
94
|
+
return JSON.parse(data);
|
|
95
|
+
}
|
|
96
|
+
if (data instanceof ArrayBuffer) {
|
|
97
|
+
return decode(new Uint8Array(data));
|
|
98
|
+
}
|
|
99
|
+
if (data instanceof Uint8Array) {
|
|
100
|
+
return decode(data);
|
|
101
|
+
}
|
|
102
|
+
throw new Error("Unsupported websocket message payload");
|
|
103
|
+
}
|
|
104
|
+
function hashRuntimeMsg(msg, seq) {
|
|
105
|
+
const random = typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
106
|
+
? crypto.randomUUID()
|
|
107
|
+
: Math.random().toString(16).slice(2);
|
|
108
|
+
return `msg_${msg.t.toString(36)}_${seq.toString(36)}_${random.replace(/-/g, "")}`;
|
|
109
|
+
}
|
|
110
|
+
function nextMsgSeq() {
|
|
111
|
+
msgSeq = (msgSeq + 1) % Number.MAX_SAFE_INTEGER;
|
|
112
|
+
return msgSeq;
|
|
113
|
+
}
|