@43world/43chat-openclaw-plugin 0.1.6
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/README.md +101 -0
- package/REQUIREMENTS.md +573 -0
- package/index.ts +22 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +48 -0
- package/src/accounts.ts +137 -0
- package/src/bot.ts +484 -0
- package/src/channel.ts +415 -0
- package/src/client.ts +433 -0
- package/src/config-schema.ts +37 -0
- package/src/monitor.ts +277 -0
- package/src/outbound.ts +59 -0
- package/src/plugin-sdk-compat.ts +27 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +35 -0
- package/src/targets.ts +58 -0
- package/src/types.ts +182 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import type { Resolved43ChatAccount, Chat43AgentProfile, Chat43OpenApiResponse, Chat43Probe, Chat43SendResult } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type ParsedSSEFrame = {
|
|
4
|
+
id?: string;
|
|
5
|
+
event?: string;
|
|
6
|
+
data?: string;
|
|
7
|
+
comment?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export class Chat43ApiError extends Error {
|
|
11
|
+
readonly status?: number;
|
|
12
|
+
readonly code?: number;
|
|
13
|
+
readonly retryable: boolean;
|
|
14
|
+
readonly responseBody?: string;
|
|
15
|
+
|
|
16
|
+
constructor(params: {
|
|
17
|
+
message: string;
|
|
18
|
+
status?: number;
|
|
19
|
+
code?: number;
|
|
20
|
+
retryable?: boolean;
|
|
21
|
+
responseBody?: string;
|
|
22
|
+
}) {
|
|
23
|
+
super(params.message);
|
|
24
|
+
this.name = "Chat43ApiError";
|
|
25
|
+
this.status = params.status;
|
|
26
|
+
this.code = params.code;
|
|
27
|
+
this.retryable = params.retryable ?? false;
|
|
28
|
+
this.responseBody = params.responseBody;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class SSEFrameParser {
|
|
33
|
+
private buffer = "";
|
|
34
|
+
private eventId: string | undefined;
|
|
35
|
+
private eventName: string | undefined;
|
|
36
|
+
private commentLines: string[] = [];
|
|
37
|
+
private dataLines: string[] = [];
|
|
38
|
+
|
|
39
|
+
feed(chunk: string): ParsedSSEFrame[] {
|
|
40
|
+
this.buffer += chunk;
|
|
41
|
+
const frames: ParsedSSEFrame[] = [];
|
|
42
|
+
|
|
43
|
+
while (true) {
|
|
44
|
+
const newlineIndex = this.buffer.indexOf("\n");
|
|
45
|
+
if (newlineIndex < 0) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let line = this.buffer.slice(0, newlineIndex);
|
|
50
|
+
this.buffer = this.buffer.slice(newlineIndex + 1);
|
|
51
|
+
if (line.endsWith("\r")) {
|
|
52
|
+
line = line.slice(0, -1);
|
|
53
|
+
}
|
|
54
|
+
this.consumeLine(line, frames);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return frames;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
finish(): ParsedSSEFrame[] {
|
|
61
|
+
const frames: ParsedSSEFrame[] = [];
|
|
62
|
+
if (this.buffer.length > 0) {
|
|
63
|
+
this.consumeLine(this.buffer, frames);
|
|
64
|
+
this.buffer = "";
|
|
65
|
+
}
|
|
66
|
+
this.flush(frames);
|
|
67
|
+
return frames;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private consumeLine(line: string, frames: ParsedSSEFrame[]): void {
|
|
71
|
+
if (line === "") {
|
|
72
|
+
this.flush(frames);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (line.startsWith(":")) {
|
|
77
|
+
this.commentLines.push(line.slice(1).trim());
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const separatorIndex = line.indexOf(":");
|
|
82
|
+
const field = separatorIndex >= 0 ? line.slice(0, separatorIndex) : line;
|
|
83
|
+
let value = separatorIndex >= 0 ? line.slice(separatorIndex + 1) : "";
|
|
84
|
+
if (value.startsWith(" ")) {
|
|
85
|
+
value = value.slice(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
switch (field) {
|
|
89
|
+
case "id":
|
|
90
|
+
this.eventId = value;
|
|
91
|
+
break;
|
|
92
|
+
case "event":
|
|
93
|
+
this.eventName = value;
|
|
94
|
+
break;
|
|
95
|
+
case "data":
|
|
96
|
+
this.dataLines.push(value);
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private flush(frames: ParsedSSEFrame[]): void {
|
|
104
|
+
if (
|
|
105
|
+
this.eventId === undefined
|
|
106
|
+
&& this.eventName === undefined
|
|
107
|
+
&& this.dataLines.length === 0
|
|
108
|
+
&& this.commentLines.length === 0
|
|
109
|
+
) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
frames.push({
|
|
114
|
+
id: this.eventId,
|
|
115
|
+
event: this.eventName,
|
|
116
|
+
data: this.dataLines.length > 0 ? this.dataLines.join("\n") : undefined,
|
|
117
|
+
comment: this.commentLines.length > 0 ? this.commentLines.join("\n") : undefined,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this.eventId = undefined;
|
|
121
|
+
this.eventName = undefined;
|
|
122
|
+
this.commentLines = [];
|
|
123
|
+
this.dataLines = [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): {
|
|
128
|
+
signal: AbortSignal;
|
|
129
|
+
cleanup: () => void;
|
|
130
|
+
} {
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const timeout = setTimeout(() => controller.abort(new Error("Request timed out")), timeoutMs);
|
|
133
|
+
const abortFromParent = () => controller.abort(signal?.reason);
|
|
134
|
+
signal?.addEventListener("abort", abortFromParent, { once: true });
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
cleanup: () => {
|
|
139
|
+
clearTimeout(timeout);
|
|
140
|
+
signal?.removeEventListener("abort", abortFromParent);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function createAuthHeaders(apiKey: string | undefined, extra?: any): Headers {
|
|
146
|
+
const headers = new Headers(extra);
|
|
147
|
+
if (apiKey) {
|
|
148
|
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
149
|
+
}
|
|
150
|
+
return headers;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isOpenApiSuccess<T>(value: unknown): value is Chat43OpenApiResponse<T> {
|
|
154
|
+
return Boolean(value) && typeof value === "object" && typeof (value as { code?: unknown }).code === "number";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function parseResponseJson<T>(response: Response): Promise<Chat43OpenApiResponse<T> | null> {
|
|
158
|
+
const text = await response.text();
|
|
159
|
+
if (!text.trim()) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(text) as Chat43OpenApiResponse<T>;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type ConnectSSEOptions<T> = {
|
|
170
|
+
signal?: AbortSignal;
|
|
171
|
+
onOpen?: () => void;
|
|
172
|
+
onEvent: (event: T) => Promise<void> | void;
|
|
173
|
+
onHeartbeat?: () => void;
|
|
174
|
+
onInvalidFrame?: (frame: ParsedSSEFrame, reason: string) => void;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
type SendTextParams = {
|
|
178
|
+
targetType: "user" | "group";
|
|
179
|
+
targetId: string;
|
|
180
|
+
text: string;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export function create43ChatClient(account: Resolved43ChatAccount) {
|
|
184
|
+
const baseUrl = account.baseUrl;
|
|
185
|
+
const apiKey = account.apiKey;
|
|
186
|
+
const requestTimeoutMs = account.config.requestTimeoutMs ?? 15_000;
|
|
187
|
+
|
|
188
|
+
if (!baseUrl || !apiKey) {
|
|
189
|
+
throw new Error(`43Chat account "${account.accountId}" not configured`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function requestJson<T>(
|
|
193
|
+
path: string,
|
|
194
|
+
init: RequestInit = {},
|
|
195
|
+
): Promise<Chat43OpenApiResponse<T>> {
|
|
196
|
+
const { signal, cleanup } = withTimeout(init.signal ?? undefined, requestTimeoutMs);
|
|
197
|
+
try {
|
|
198
|
+
const response = await fetch(new URL(path, `${baseUrl}/`).toString(), {
|
|
199
|
+
...init,
|
|
200
|
+
signal,
|
|
201
|
+
headers: createAuthHeaders(apiKey, {
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
Accept: "application/json",
|
|
204
|
+
...init.headers,
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const json = await parseResponseJson<T>(response);
|
|
209
|
+
const message = json?.message || `${response.status} ${response.statusText}`;
|
|
210
|
+
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
throw new Chat43ApiError({
|
|
213
|
+
message,
|
|
214
|
+
status: response.status,
|
|
215
|
+
code: json?.code,
|
|
216
|
+
retryable: response.status >= 500 || response.status === 429,
|
|
217
|
+
responseBody: json ? JSON.stringify(json) : undefined,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!json || !isOpenApiSuccess<T>(json)) {
|
|
222
|
+
throw new Chat43ApiError({
|
|
223
|
+
message: "43Chat returned an invalid JSON response",
|
|
224
|
+
status: response.status,
|
|
225
|
+
retryable: false,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (json.code !== 0) {
|
|
230
|
+
throw new Chat43ApiError({
|
|
231
|
+
message: json.message || "43Chat API request failed",
|
|
232
|
+
status: response.status,
|
|
233
|
+
code: json.code,
|
|
234
|
+
retryable: response.status >= 500 || response.status === 429,
|
|
235
|
+
responseBody: JSON.stringify(json),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return json;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (err instanceof Chat43ApiError) {
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
245
|
+
throw new Chat43ApiError({
|
|
246
|
+
message,
|
|
247
|
+
retryable: true,
|
|
248
|
+
});
|
|
249
|
+
} finally {
|
|
250
|
+
cleanup();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function connectSSE<T>(options: ConnectSSEOptions<T>): Promise<void> {
|
|
255
|
+
const response = await fetch(new URL("/open/events/stream", `${baseUrl}/`).toString(), {
|
|
256
|
+
method: "GET",
|
|
257
|
+
signal: options.signal,
|
|
258
|
+
headers: createAuthHeaders(apiKey, {
|
|
259
|
+
Accept: "text/event-stream",
|
|
260
|
+
"Cache-Control": "no-cache",
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
const json = await parseResponseJson<unknown>(response);
|
|
266
|
+
throw new Chat43ApiError({
|
|
267
|
+
message: json?.message || `${response.status} ${response.statusText}`,
|
|
268
|
+
status: response.status,
|
|
269
|
+
code: json?.code,
|
|
270
|
+
retryable: response.status >= 500 || response.status === 429,
|
|
271
|
+
responseBody: json ? JSON.stringify(json) : undefined,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!response.body) {
|
|
276
|
+
throw new Chat43ApiError({
|
|
277
|
+
message: "43Chat SSE response body is empty",
|
|
278
|
+
retryable: true,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
options.onOpen?.();
|
|
283
|
+
|
|
284
|
+
const parser = new SSEFrameParser();
|
|
285
|
+
const reader = response.body.getReader();
|
|
286
|
+
const decoder = new TextDecoder();
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
while (true) {
|
|
290
|
+
const { done, value } = await reader.read();
|
|
291
|
+
if (done) {
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
295
|
+
const frames = parser.feed(chunk);
|
|
296
|
+
for (const frame of frames) {
|
|
297
|
+
if (frame.comment?.includes("heartbeat")) {
|
|
298
|
+
options.onHeartbeat?.();
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!frame.data) {
|
|
303
|
+
options.onInvalidFrame?.(frame, "missing_data");
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
await options.onEvent(JSON.parse(frame.data) as T);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
options.onInvalidFrame?.(
|
|
311
|
+
frame,
|
|
312
|
+
err instanceof Error ? err.message : String(err),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const trailingFrames = parser.finish();
|
|
319
|
+
for (const frame of trailingFrames) {
|
|
320
|
+
if (frame.comment?.includes("heartbeat")) {
|
|
321
|
+
options.onHeartbeat?.();
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!frame.data) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
await options.onEvent(JSON.parse(frame.data) as T);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
options.onInvalidFrame?.(frame, err instanceof Error ? err.message : String(err));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (options.signal?.aborted) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
throw err;
|
|
338
|
+
} finally {
|
|
339
|
+
reader.releaseLock();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!options.signal?.aborted) {
|
|
343
|
+
throw new Chat43ApiError({
|
|
344
|
+
message: "43Chat SSE stream closed",
|
|
345
|
+
retryable: true,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function getProfile(): Promise<Chat43AgentProfile> {
|
|
351
|
+
const response = await requestJson<Chat43AgentProfile>("/open/agent/profile", {
|
|
352
|
+
method: "GET",
|
|
353
|
+
});
|
|
354
|
+
if (!response.data) {
|
|
355
|
+
throw new Chat43ApiError({
|
|
356
|
+
message: "43Chat profile response missing data",
|
|
357
|
+
retryable: false,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return response.data;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function sendText(params: SendTextParams): Promise<Chat43SendResult> {
|
|
364
|
+
const path = params.targetType === "user"
|
|
365
|
+
? "/open/message/private/send"
|
|
366
|
+
: "/open/message/group/send";
|
|
367
|
+
const body = params.targetType === "user"
|
|
368
|
+
? {
|
|
369
|
+
to_user_id: Number(params.targetId),
|
|
370
|
+
content: params.text,
|
|
371
|
+
msg_type: "text",
|
|
372
|
+
}
|
|
373
|
+
: {
|
|
374
|
+
group_id: Number(params.targetId),
|
|
375
|
+
content: params.text,
|
|
376
|
+
msg_type: "text",
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const response = await requestJson<{ message_id: string; sent_at: number }>(path, {
|
|
380
|
+
method: "POST",
|
|
381
|
+
body: JSON.stringify(body),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
messageId: response.data?.message_id ?? `43chat_${Date.now()}`,
|
|
386
|
+
chatId: `${params.targetType}:${params.targetId}`,
|
|
387
|
+
targetType: params.targetType,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
requestJson,
|
|
393
|
+
connectSSE,
|
|
394
|
+
getProfile,
|
|
395
|
+
sendText,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function probe43ChatAccount(params: {
|
|
400
|
+
account: Resolved43ChatAccount;
|
|
401
|
+
timeoutMs?: number;
|
|
402
|
+
}): Promise<Chat43Probe> {
|
|
403
|
+
const { account, timeoutMs } = params;
|
|
404
|
+
if (!account.configured) {
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
error: "43Chat account not configured",
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const client = create43ChatClient({
|
|
413
|
+
...account,
|
|
414
|
+
config: {
|
|
415
|
+
...account.config,
|
|
416
|
+
requestTimeoutMs: timeoutMs ?? account.config.requestTimeoutMs,
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
const profile = await client.getProfile();
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
agentId: profile.agent_id,
|
|
423
|
+
userId: profile.user_id,
|
|
424
|
+
name: profile.name,
|
|
425
|
+
status: profile.status,
|
|
426
|
+
};
|
|
427
|
+
} catch (err) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
error: err instanceof Error ? err.message : String(err),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export { z };
|
|
4
|
+
|
|
5
|
+
const DmPolicySchema = z.enum(["open", "pairing"]).default("open");
|
|
6
|
+
const ChunkModeSchema = z.enum(["length", "newline", "raw"]).default("newline");
|
|
7
|
+
|
|
8
|
+
const Chat43SharedConfigShape = {
|
|
9
|
+
dmPolicy: DmPolicySchema.optional(),
|
|
10
|
+
allowFrom: z.array(z.string()).optional(),
|
|
11
|
+
requestTimeoutMs: z.number().int().positive().optional(),
|
|
12
|
+
sseReconnectDelayMs: z.number().int().positive().optional(),
|
|
13
|
+
sseMaxReconnectDelayMs: z.number().int().positive().optional(),
|
|
14
|
+
textChunkLimit: z.number().int().positive().optional(),
|
|
15
|
+
chunkMode: ChunkModeSchema,
|
|
16
|
+
blockStreaming: z.boolean().default(false),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const Chat43AccountConfigSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
enabled: z.boolean().optional(),
|
|
22
|
+
name: z.string().optional(),
|
|
23
|
+
baseUrl: z.string().url().optional(),
|
|
24
|
+
apiKey: z.string().optional(),
|
|
25
|
+
...Chat43SharedConfigShape,
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
|
|
29
|
+
export const Chat43ConfigSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
enabled: z.boolean().optional(),
|
|
32
|
+
baseUrl: z.string().url().optional(),
|
|
33
|
+
apiKey: z.string().optional(),
|
|
34
|
+
...Chat43SharedConfigShape,
|
|
35
|
+
accounts: z.record(z.string(), Chat43AccountConfigSchema.optional()).optional(),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|