@wecode-ai/weibo-openclaw-plugin 1.0.0

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/src/client.ts ADDED
@@ -0,0 +1,435 @@
1
+ import { createRequire } from "module";
2
+ import WebSocket from "ws";
3
+ import type { ResolvedWeiboAccount, WeiboRuntimeStatusPatch } from "./types.js";
4
+
5
+ // Read version from package.json
6
+ const require = createRequire(import.meta.url);
7
+ const { version: PLUGIN_VERSION } = require("../package.json") as { version: string };
8
+ import {
9
+ getValidToken,
10
+ clearTokenCache,
11
+ formatWeiboTokenFetchErrorMessage,
12
+ isRetryableWeiboTokenFetchError,
13
+ } from "./token.js";
14
+ import { getWeiboConnectionFingerprint } from "./fingerprint.js";
15
+
16
+ export type WebSocketMessageHandler = (data: unknown) => void;
17
+ export type WebSocketErrorHandler = (error: Error) => void;
18
+ export type WebSocketCloseHandler = (code: number, reason: string) => void;
19
+ export type WebSocketOpenHandler = () => void;
20
+ export type WebSocketStatusHandler = (patch: WeiboRuntimeStatusPatch) => void;
21
+
22
+ // Ping interval: 30 seconds
23
+ const PING_INTERVAL_MS = 30_000;
24
+ // Initial reconnect delay: 1 second
25
+ const INITIAL_RECONNECT_DELAY_MS = 1_000;
26
+ // Maximum reconnect delay: 60 seconds
27
+ const MAX_RECONNECT_DELAY_MS = 60_000;
28
+ // Maximum reconnect attempts (0 = infinite)
29
+ const MAX_RECONNECT_ATTEMPTS = 0;
30
+
31
+ export type WeiboClientOptions = {
32
+ onMessage?: WebSocketMessageHandler;
33
+ onError?: WebSocketErrorHandler;
34
+ onClose?: WebSocketCloseHandler;
35
+ onOpen?: WebSocketOpenHandler;
36
+ onStatus?: WebSocketStatusHandler;
37
+ autoReconnect?: boolean;
38
+ maxReconnectAttempts?: number;
39
+ };
40
+
41
+ function isRetryableConnectError(err: unknown): boolean {
42
+ const retryableTokenError = isRetryableWeiboTokenFetchError(err);
43
+ if (retryableTokenError !== null) {
44
+ return retryableTokenError;
45
+ }
46
+
47
+ if (err instanceof Error) {
48
+ const message = err.message.toLowerCase();
49
+ if (message.includes("not configured")) {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ return true;
55
+ }
56
+
57
+ export class WeiboWebSocketClient {
58
+ private ws: WebSocket | null = null;
59
+ private messageHandler: WebSocketMessageHandler | null = null;
60
+ private errorHandler: WebSocketErrorHandler | null = null;
61
+ private closeHandler: WebSocketCloseHandler | null = null;
62
+ private openHandler: WebSocketOpenHandler | null = null;
63
+ private statusHandler: WebSocketStatusHandler | null = null;
64
+
65
+ // Heartbeat
66
+ private pingInterval: NodeJS.Timeout | null = null;
67
+ private lastPongTime: number = 0;
68
+ private readonly PING_TIMEOUT_MS = 10_000;
69
+
70
+ // Reconnection
71
+ private reconnectAttempts = 0;
72
+ private reconnectTimeout: NodeJS.Timeout | null = null;
73
+ private isConnecting = false;
74
+ private shouldReconnect = true;
75
+ private autoReconnect: boolean;
76
+ private maxReconnectAttempts: number;
77
+
78
+ constructor(
79
+ private account: ResolvedWeiboAccount,
80
+ private options: WeiboClientOptions = {}
81
+ ) {
82
+ this.autoReconnect = options.autoReconnect ?? true;
83
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? MAX_RECONNECT_ATTEMPTS;
84
+ this.messageHandler = options.onMessage ?? null;
85
+ this.errorHandler = options.onError ?? null;
86
+ this.closeHandler = options.onClose ?? null;
87
+ this.openHandler = options.onOpen ?? null;
88
+ this.statusHandler = options.onStatus ?? null;
89
+ }
90
+
91
+ private emitStatus(patch: WeiboRuntimeStatusPatch): void {
92
+ this.statusHandler?.(patch);
93
+ }
94
+
95
+ async connect(): Promise<void> {
96
+ if (this.isConnecting || this.ws?.readyState === WebSocket.OPEN) {
97
+ return;
98
+ }
99
+
100
+ this.isConnecting = true;
101
+ this.shouldReconnect = true;
102
+ this.emitStatus({
103
+ running: true,
104
+ connected: false,
105
+ connectionState: "connecting",
106
+ nextRetryAt: null,
107
+ lastError: null,
108
+ });
109
+
110
+ const { wsEndpoint, appId, tokenEndpoint } = this.account;
111
+
112
+ if (!wsEndpoint) {
113
+ this.isConnecting = false;
114
+ throw new Error(
115
+ `WebSocket endpoint not configured for account "${this.account.accountId}"`
116
+ );
117
+ }
118
+
119
+ if (!appId) {
120
+ this.isConnecting = false;
121
+ throw new Error(
122
+ `App ID not configured for account "${this.account.accountId}"`
123
+ );
124
+ }
125
+
126
+ try {
127
+ // Fetch token from API
128
+ const token = await getValidToken(this.account, tokenEndpoint);
129
+
130
+ const url = new URL(wsEndpoint);
131
+ url.searchParams.set("app_id", appId);
132
+ url.searchParams.set("token", token);
133
+ url.searchParams.set("version", PLUGIN_VERSION);
134
+
135
+ this.ws = new WebSocket(url.toString());
136
+
137
+ this.ws.on("open", () => {
138
+ this.isConnecting = false;
139
+ this.reconnectAttempts = 0;
140
+ this.lastPongTime = Date.now();
141
+ this.startHeartbeat();
142
+ this.emitStatus({
143
+ running: true,
144
+ connected: true,
145
+ connectionState: "connected",
146
+ reconnectAttempts: 0,
147
+ nextRetryAt: null,
148
+ lastConnectedAt: Date.now(),
149
+ lastError: null,
150
+ });
151
+ this.openHandler?.();
152
+ });
153
+
154
+ this.ws.on("message", (data) => {
155
+ try {
156
+ const text = data.toString();
157
+
158
+ // Handle pong response
159
+ if (text === "pong" || text === "{\"type\":\"pong\"}") {
160
+ this.lastPongTime = Date.now();
161
+ return;
162
+ }
163
+
164
+ const parsed = JSON.parse(text);
165
+ this.messageHandler?.(parsed);
166
+ } catch {
167
+ // Ignore invalid JSON
168
+ }
169
+ });
170
+
171
+ this.ws.on("error", (err) => {
172
+ this.emitStatus({
173
+ running: true,
174
+ connected: false,
175
+ connectionState: "error",
176
+ lastError: err.message,
177
+ });
178
+ this.errorHandler?.(err);
179
+ });
180
+
181
+ this.ws.on("close", (code, reason) => {
182
+ this.isConnecting = false;
183
+ this.stopHeartbeat();
184
+ const reasonStr = reason.toString() || "unknown";
185
+ if (code === 4002 || /invalid token/i.test(reasonStr)) {
186
+ clearTokenCache(this.account.accountId);
187
+ }
188
+ this.emitStatus({
189
+ running: this.shouldReconnect,
190
+ connected: false,
191
+ connectionState:
192
+ this.shouldReconnect && this.autoReconnect ? "error" : "stopped",
193
+ lastDisconnect: {
194
+ code,
195
+ reason: reasonStr,
196
+ at: Date.now(),
197
+ },
198
+ });
199
+ this.closeHandler?.(code, reasonStr);
200
+
201
+ // Auto reconnect if enabled
202
+ if (this.shouldReconnect && this.autoReconnect) {
203
+ this.scheduleReconnect(reasonStr);
204
+ }
205
+ });
206
+
207
+ this.ws.on("pong", () => {
208
+ this.lastPongTime = Date.now();
209
+ });
210
+ } catch (err) {
211
+ this.isConnecting = false;
212
+ const message =
213
+ formatWeiboTokenFetchErrorMessage(err)
214
+ ?? (err instanceof Error ? err.message : String(err));
215
+ this.emitStatus({
216
+ running: true,
217
+ connected: false,
218
+ connectionState: "error",
219
+ lastError: message,
220
+ });
221
+ // Schedule reconnect on connection failure
222
+ if (this.shouldReconnect && this.autoReconnect && isRetryableConnectError(err)) {
223
+ this.scheduleReconnect(message);
224
+ }
225
+ throw err;
226
+ }
227
+ }
228
+
229
+ private startHeartbeat(): void {
230
+ this.stopHeartbeat();
231
+
232
+ this.pingInterval = setInterval(() => {
233
+ if (this.ws?.readyState === WebSocket.OPEN) {
234
+ // Check if we received pong recently
235
+ const timeSinceLastPong = Date.now() - this.lastPongTime;
236
+ if (timeSinceLastPong > PING_INTERVAL_MS + this.PING_TIMEOUT_MS) {
237
+ // Connection may be dead, close and reconnect
238
+ console.warn(
239
+ `weibo[${this.account.accountId}]: pong timeout, closing connection`
240
+ );
241
+ this.ws.terminate();
242
+ return;
243
+ }
244
+
245
+ // Send ping
246
+ try {
247
+ this.ws.send(JSON.stringify({ type: "ping" }));
248
+ } catch (err) {
249
+ console.error(
250
+ `weibo[${this.account.accountId}]: failed to send ping:`,
251
+ err
252
+ );
253
+ }
254
+ }
255
+ }, PING_INTERVAL_MS);
256
+ }
257
+
258
+ private stopHeartbeat(): void {
259
+ if (this.pingInterval) {
260
+ clearInterval(this.pingInterval);
261
+ this.pingInterval = null;
262
+ }
263
+ }
264
+
265
+ private scheduleReconnect(lastError?: string): void {
266
+ if (this.reconnectTimeout) {
267
+ return;
268
+ }
269
+
270
+ // Check max reconnect attempts
271
+ if (
272
+ this.maxReconnectAttempts > 0 &&
273
+ this.reconnectAttempts >= this.maxReconnectAttempts
274
+ ) {
275
+ console.error(
276
+ `weibo[${this.account.accountId}]: max reconnect attempts reached`
277
+ );
278
+ return;
279
+ }
280
+
281
+ // Calculate delay with exponential backoff
282
+ const delay = Math.min(
283
+ INITIAL_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts),
284
+ MAX_RECONNECT_DELAY_MS
285
+ );
286
+
287
+ this.reconnectAttempts++;
288
+ this.emitStatus({
289
+ running: true,
290
+ connected: false,
291
+ connectionState: "backoff",
292
+ reconnectAttempts: this.reconnectAttempts,
293
+ nextRetryAt: Date.now() + delay,
294
+ lastError: lastError ?? null,
295
+ });
296
+
297
+ console.log(
298
+ `weibo[${this.account.accountId}]: reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`
299
+ );
300
+
301
+ this.reconnectTimeout = setTimeout(() => {
302
+ this.reconnectTimeout = null;
303
+ this.connect().catch((err) => {
304
+ console.error(
305
+ `weibo[${this.account.accountId}]: reconnect failed:`,
306
+ err
307
+ );
308
+ });
309
+ }, delay);
310
+ }
311
+
312
+ onMessage(handler: WebSocketMessageHandler): void {
313
+ this.messageHandler = handler;
314
+ }
315
+
316
+ onError(handler: WebSocketErrorHandler): void {
317
+ this.errorHandler = handler;
318
+ }
319
+
320
+ onClose(handler: WebSocketCloseHandler): void {
321
+ this.closeHandler = handler;
322
+ }
323
+
324
+ onOpen(handler: WebSocketOpenHandler): void {
325
+ this.openHandler = handler;
326
+ }
327
+
328
+ onStatus(handler: WebSocketStatusHandler): void {
329
+ this.statusHandler = handler;
330
+ }
331
+
332
+ send(data: unknown): boolean {
333
+ if (this.ws?.readyState === WebSocket.OPEN) {
334
+ try {
335
+ this.ws.send(JSON.stringify(data));
336
+ return true;
337
+ } catch (err) {
338
+ console.error(
339
+ `weibo[${this.account.accountId}]: failed to send message:`,
340
+ err
341
+ );
342
+ return false;
343
+ }
344
+ }
345
+ return false;
346
+ }
347
+
348
+ isConnected(): boolean {
349
+ return this.ws?.readyState === WebSocket.OPEN;
350
+ }
351
+
352
+ close(): void {
353
+ this.shouldReconnect = false;
354
+ this.stopHeartbeat();
355
+
356
+ if (this.reconnectTimeout) {
357
+ clearTimeout(this.reconnectTimeout);
358
+ this.reconnectTimeout = null;
359
+ }
360
+
361
+ this.ws?.close();
362
+ this.ws = null;
363
+ this.emitStatus({
364
+ running: false,
365
+ connected: false,
366
+ connectionState: "stopped",
367
+ nextRetryAt: null,
368
+ lastStopAt: Date.now(),
369
+ });
370
+ }
371
+ }
372
+
373
+ type ClientCacheEntry = {
374
+ client: WeiboWebSocketClient;
375
+ fingerprint: string;
376
+ };
377
+
378
+ function applyOptions(client: WeiboWebSocketClient, options?: WeiboClientOptions): void {
379
+ if (!options) {
380
+ return;
381
+ }
382
+ if (options.onMessage) {
383
+ client.onMessage(options.onMessage);
384
+ }
385
+ if (options.onError) {
386
+ client.onError(options.onError);
387
+ }
388
+ if (options.onClose) {
389
+ client.onClose(options.onClose);
390
+ }
391
+ if (options.onOpen) {
392
+ client.onOpen(options.onOpen);
393
+ }
394
+ if (options.onStatus) {
395
+ client.onStatus(options.onStatus);
396
+ }
397
+ }
398
+
399
+ // Client cache for reusing connections
400
+ const clientCache = new Map<string, ClientCacheEntry>();
401
+
402
+ export function createWeiboClient(
403
+ account: ResolvedWeiboAccount,
404
+ options?: WeiboClientOptions
405
+ ): WeiboWebSocketClient {
406
+ const fingerprint = getWeiboConnectionFingerprint(account);
407
+ const cached = clientCache.get(account.accountId);
408
+ if (cached) {
409
+ if (cached.fingerprint === fingerprint) {
410
+ applyOptions(cached.client, options);
411
+ return cached.client;
412
+ }
413
+ cached.client.close();
414
+ clearTokenCache(account.accountId);
415
+ clientCache.delete(account.accountId);
416
+ }
417
+ const client = new WeiboWebSocketClient(account, options);
418
+ clientCache.set(account.accountId, { client, fingerprint });
419
+ return client;
420
+ }
421
+
422
+ export function clearClientCache(accountId?: string): void {
423
+ if (accountId) {
424
+ const cached = clientCache.get(accountId);
425
+ if (cached) {
426
+ cached.client.close();
427
+ clientCache.delete(accountId);
428
+ }
429
+ } else {
430
+ for (const cached of clientCache.values()) {
431
+ cached.client.close();
432
+ }
433
+ clientCache.clear();
434
+ }
435
+ }
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ export { z };
3
+
4
+ const DmPolicySchema = z.enum(["open", "pairing"]).default("open");
5
+
6
+ // Chunk mode:
7
+ // - length: split by character limit
8
+ // - newline: split at paragraph boundaries (blank lines)
9
+ // - raw: forward upstream chunks as-is (no secondary chunking)
10
+ const ChunkModeSchema = z.enum(["length", "newline", "raw"]).default("raw");
11
+
12
+ /**
13
+ * Weibo tools configuration.
14
+ * Controls which tool categories are enabled.
15
+ */
16
+ export const WeiboToolsConfigSchema = z
17
+ .object({
18
+ search: z.boolean().optional(), // Search operations (default: true)
19
+ myWeibo: z.boolean().optional(), // My weibo operations (default: true)
20
+ hotSearch: z.boolean().optional(), // Hot search operations (default: true)
21
+ })
22
+ .strict()
23
+ .optional();
24
+
25
+ const WeiboSharedConfigShape = {
26
+ dmPolicy: DmPolicySchema.optional(),
27
+ allowFrom: z.array(z.string()).optional(),
28
+ textChunkLimit: z.number().int().positive().optional(),
29
+ chunkMode: ChunkModeSchema,
30
+ // Whether to allow OpenClaw block streaming for this channel/account.
31
+ // true: stream block replies progressively; false: final-only delivery.
32
+ blockStreaming: z.boolean().default(true),
33
+ tools: WeiboToolsConfigSchema,
34
+ };
35
+
36
+ export const WeiboAccountConfigSchema = z
37
+ .object({
38
+ enabled: z.boolean().optional(),
39
+ name: z.string().optional(),
40
+ appId: z.string().optional(),
41
+ appSecret: z.string().optional(),
42
+ wsEndpoint: z.string().url().default("ws://open-im.api.weibo.com/ws/stream"),
43
+ tokenEndpoint: z.string().url().default("http://open-im.api.weibo.com/open/auth/ws_token"),
44
+ ...WeiboSharedConfigShape,
45
+ })
46
+ .strict();
47
+
48
+ export const WeiboConfigSchema = z
49
+ .object({
50
+ enabled: z.boolean().optional(),
51
+ appId: z.string().optional(),
52
+ appSecret: z.string().optional(),
53
+ wsEndpoint: z.string().url().default("ws://open-im.api.weibo.com/ws/stream"),
54
+ tokenEndpoint: z.string().url().default("http://open-im.api.weibo.com/open/auth/ws_token"),
55
+ ...WeiboSharedConfigShape,
56
+ accounts: z.record(z.string(), WeiboAccountConfigSchema.optional()).optional(),
57
+ })
58
+ .strict();
@@ -0,0 +1,25 @@
1
+ import type { ResolvedWeiboAccount } from "./types.js";
2
+
3
+ function normalizePart(value: unknown): string {
4
+ return String(value ?? "").trim();
5
+ }
6
+
7
+ export function getWeiboConnectionFingerprint(account: ResolvedWeiboAccount): string {
8
+ return JSON.stringify({
9
+ appId: normalizePart(account.appId),
10
+ appSecret: normalizePart(account.appSecret),
11
+ wsEndpoint: normalizePart(account.wsEndpoint),
12
+ tokenEndpoint: normalizePart(account.tokenEndpoint),
13
+ });
14
+ }
15
+
16
+ export function getWeiboTokenFingerprint(
17
+ account: ResolvedWeiboAccount,
18
+ tokenEndpoint?: string,
19
+ ): string {
20
+ return JSON.stringify({
21
+ appId: normalizePart(account.appId),
22
+ appSecret: normalizePart(account.appSecret),
23
+ tokenEndpoint: normalizePart(tokenEndpoint ?? account.tokenEndpoint),
24
+ });
25
+ }