@ascegu/teamily 1.0.8 → 1.0.9

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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/monitor.ts +128 -300
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascegu/teamily",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "OpenClaw Teamily channel plugin - Team instant messaging server integration",
5
5
  "keywords": [
6
6
  "channel",
@@ -29,6 +29,7 @@
29
29
  "type": "module",
30
30
  "main": "index.ts",
31
31
  "dependencies": {
32
+ "@openim/client-sdk": "^3.8.3",
32
33
  "zod": "^4.3.6"
33
34
  },
34
35
  "openclaw": {
package/src/monitor.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { generateOperationID } from "./probe.js";
2
1
  import type {
3
2
  ResolvedTeamilyAccount,
4
3
  TeamilyMessage,
@@ -8,11 +7,6 @@ import type {
8
7
  } from "./types.js";
9
8
  import { CONTENT_TYPES, SESSION_TYPES } from "./types.js";
10
9
 
11
- const WS_REQ = {
12
- LOGIN: 1001,
13
- HEARTBEAT: 1002,
14
- } as const;
15
-
16
10
  export type TeamilyMessageHandler = (message: TeamilyMessage) => Promise<void> | void;
17
11
  export type TeamilyConnectionState = "connecting" | "connected" | "disconnected" | "error";
18
12
 
@@ -20,361 +14,198 @@ export interface TeamilyMonitorOptions {
20
14
  account: ResolvedTeamilyAccount;
21
15
  onMessage: TeamilyMessageHandler;
22
16
  onStateChange?: (state: TeamilyConnectionState, error?: string) => void;
23
- reconnectInterval?: number;
24
- pingInterval?: number;
25
- websocketImpl?: typeof WebSocket;
17
+ }
18
+
19
+ type SdkModule = typeof import("@openim/client-sdk");
20
+ type SdkInstance = ReturnType<SdkModule["getSDK"]>;
21
+
22
+ // Lazy-loaded SDK to avoid top-level dynamic import issues
23
+ let sdkModule: SdkModule | null = null;
24
+ async function loadSDK() {
25
+ if (!sdkModule) {
26
+ sdkModule = await import("@openim/client-sdk");
27
+ }
28
+ return sdkModule;
26
29
  }
27
30
 
28
31
  /**
29
- * Monitor for incoming Teamily messages via WebSocket.
32
+ * Monitor for incoming Teamily messages using @openim/client-sdk.
30
33
  *
31
- * This class manages a WebSocket connection to the Teamily server
32
- * and handles incoming messages, reconnections, and heartbeat pings.
34
+ * Delegates WebSocket connection, authentication, heartbeat, and
35
+ * reconnection to the official OpenIM SDK.
33
36
  */
34
37
  export class TeamilyMonitor {
35
38
  private account: ResolvedTeamilyAccount;
36
39
  private onMessage: TeamilyMessageHandler;
37
40
  private onStateChange?: (state: TeamilyConnectionState, error?: string) => void;
38
- private reconnectInterval: number;
39
- private pingInterval: number;
40
- private ws: WebSocket | null = null;
41
- private pingTimer: NodeJS.Timeout | null = null;
42
- private reconnectTimer: NodeJS.Timeout | null = null;
41
+ private sdk: SdkInstance | null = null;
43
42
  private state: TeamilyConnectionState = "disconnected";
44
- private shouldReconnect = true;
45
- private authenticated = false;
46
- private wsImpl: typeof WebSocket;
43
+ private stopped = false;
47
44
 
48
45
  constructor(options: TeamilyMonitorOptions) {
49
46
  this.account = options.account;
50
47
  this.onMessage = options.onMessage;
51
48
  this.onStateChange = options.onStateChange;
52
- this.reconnectInterval = options.reconnectInterval ?? 5000;
53
- this.pingInterval = options.pingInterval ?? 30000;
54
- this.wsImpl = options.websocketImpl ?? WebSocket;
55
49
  }
56
50
 
57
- /**
58
- * Start monitoring for messages.
59
- */
60
51
  async start(): Promise<void> {
61
- this.shouldReconnect = true;
62
- await this.connect();
63
- }
64
-
65
- /**
66
- * Stop monitoring and close the connection.
67
- */
68
- stop(): void {
69
- this.shouldReconnect = false;
70
- this.authenticated = false;
71
-
72
- if (this.reconnectTimer) {
73
- clearTimeout(this.reconnectTimer);
74
- this.reconnectTimer = null;
75
- }
76
-
77
- if (this.pingTimer) {
78
- clearInterval(this.pingTimer);
79
- this.pingTimer = null;
80
- }
81
-
82
- if (this.ws) {
83
- const ws = this.ws;
84
- ws.onopen = null;
85
- ws.onmessage = null;
86
- ws.onerror = null;
87
- ws.onclose = null;
88
- this.ws = null;
89
- try {
90
- ws.close(1000, "Monitoring stopped");
91
- } catch {
92
- // Ignore – socket may already be closed.
93
- }
94
- }
95
-
96
- this.setState("disconnected");
97
- }
98
-
99
- /**
100
- * Connect to the Teamily WebSocket server.
101
- */
102
- private async connect(): Promise<void> {
103
- if (!this.shouldReconnect) {
104
- return;
105
- }
106
-
52
+ this.stopped = false;
107
53
  this.setState("connecting");
108
54
 
109
- const wsUrl = new URL(this.account.wsURL);
110
- wsUrl.searchParams.set("sendID", this.account.userID);
111
- wsUrl.searchParams.set("token", this.account.token);
112
- wsUrl.searchParams.set("platformID", "5");
113
- wsUrl.searchParams.set("sdkType", "js");
114
-
115
- try {
116
- const ws = new this.wsImpl(wsUrl.toString()) as WebSocket & { isMock?: boolean };
117
- this.ws = ws;
118
-
119
- ws.onopen = () => this.handleOpen();
120
- ws.onmessage = (event) => this.handleMessage(event);
121
- ws.onerror = (error) => this.handleError(error);
122
- ws.onclose = () => this.handleClose();
123
- } catch (error) {
124
- this.handleError(error);
125
- }
126
- }
127
-
128
- /**
129
- * Handle WebSocket connection opened.
130
- */
131
- private handleOpen(): void {
132
- this.sendAuth();
133
- }
134
-
135
- /**
136
- * Send authentication message after WebSocket connection opens.
137
- */
138
- private sendAuth(): void {
139
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
140
- return;
141
- }
142
- try {
143
- this.ws.send(
144
- JSON.stringify({
145
- reqIdentifier: WS_REQ.LOGIN,
146
- operationID: generateOperationID(),
147
- sendID: this.account.userID,
148
- token: this.account.token,
149
- platformID: 5,
150
- }),
151
- );
152
- } catch (error) {
153
- this.handleError(error);
154
- }
155
- }
156
-
157
- /**
158
- * Handle incoming WebSocket message.
159
- */
160
- private async handleMessage(event: MessageEvent): Promise<void> {
161
- try {
162
- const data = JSON.parse(event.data) as {
163
- reqIdentifier?: number;
164
- errCode?: number;
165
- errMsg?: string;
166
- msgID?: string;
167
- sendID?: string;
168
- msgFrom?: string;
169
- recvID?: string;
170
- contentType?: number;
171
- content?: unknown;
172
- sessionType?: number;
173
- sendTime?: number;
174
- };
175
-
176
- // Handle login response
177
- if (data.reqIdentifier === WS_REQ.LOGIN) {
178
- if (data.errCode === 0) {
179
- this.authenticated = true;
180
- this.setState("connected");
181
- this.startPing();
182
- } else {
183
- this.setState("error", data.errMsg || "Authentication failed");
184
- this.ws?.close();
55
+ const { getSDK, CbEvents } = await loadSDK();
56
+ const sdk = getSDK();
57
+ this.sdk = sdk;
58
+
59
+ // Connection events
60
+ sdk.on(CbEvents.OnConnecting, () => {
61
+ if (!this.stopped) this.setState("connecting");
62
+ });
63
+ sdk.on(CbEvents.OnConnectSuccess, () => {
64
+ if (!this.stopped) this.setState("connected");
65
+ });
66
+ sdk.on(CbEvents.OnConnectFailed, ({ errCode, errMsg }) => {
67
+ if (!this.stopped) this.setState("error", `[${errCode}] ${errMsg}`);
68
+ });
69
+ sdk.on(CbEvents.OnKickedOffline, () => {
70
+ if (!this.stopped) this.setState("error", "Kicked offline");
71
+ });
72
+ sdk.on(CbEvents.OnUserTokenExpired, () => {
73
+ if (!this.stopped) this.setState("error", "Token expired");
74
+ });
75
+
76
+ // Incoming messages
77
+ sdk.on(CbEvents.OnRecvNewMessages, ({ data }) => {
78
+ if (this.stopped || !data) return;
79
+ for (const msg of data) {
80
+ // Skip self-sent messages
81
+ if (msg.sendID === this.account.userID) continue;
82
+ const converted = convertSdkMessage(msg, this.account.userID);
83
+ if (converted) {
84
+ // Fire-and-forget; errors are logged by the gateway dispatcher
85
+ void this.onMessage(converted);
185
86
  }
186
- return;
187
- }
188
-
189
- // Ignore heartbeat responses
190
- if (data.reqIdentifier === WS_REQ.HEARTBEAT) {
191
- return;
192
- }
193
-
194
- if (!this.authenticated) {
195
- return;
196
87
  }
88
+ });
197
89
 
198
- const contentType = data.contentType || CONTENT_TYPES.TEXT;
199
-
200
- const message: TeamilyMessage = {
201
- serverMsgID: data.msgID || `${Date.now()}_${Math.random()}`,
202
- sendID: data.sendID || data.msgFrom || "unknown",
203
- recvID: data.recvID || this.account.userID,
204
- content: parseMessageContent(data.content, contentType),
205
- contentType,
206
- sessionType: data.sessionType || SESSION_TYPES.SINGLE,
207
- sendTime: data.sendTime || Date.now(),
208
- };
209
-
210
- await this.onMessage(message);
211
- } catch (error) {
212
- console.error("Failed to parse Teamily message:", error);
213
- }
214
- }
215
-
216
- /**
217
- * Handle WebSocket error.
218
- * Detaches event handlers before closing to prevent recursive calls
219
- * (ws.close() on an errored socket can re-fire onerror → stack overflow).
220
- */
221
- private handleError(error: unknown): void {
222
- const errorMessage = error instanceof Error ? error.message : String(error);
223
- this.setState("error", errorMessage);
224
-
225
- // Detach handlers and grab ref before nulling, so close() cannot recurse.
226
- const ws = this.ws;
227
- if (ws) {
228
- ws.onopen = null;
229
- ws.onmessage = null;
230
- ws.onerror = null;
231
- ws.onclose = null;
232
- this.ws = null;
233
- try {
234
- ws.close();
235
- } catch {
236
- // Ignore – socket may already be closed/invalid.
237
- }
238
- // onclose was detached, so manually trigger reconnect logic.
239
- this.handleClose();
240
- }
241
- }
242
-
243
- /**
244
- * Handle WebSocket connection closed.
245
- */
246
- private handleClose(): void {
247
- this.stopPing();
248
- this.authenticated = false;
249
-
250
- if (this.state === "disconnected" || !this.shouldReconnect) {
251
- return;
90
+ try {
91
+ await sdk.login({
92
+ userID: this.account.userID,
93
+ token: this.account.token,
94
+ platformID: 5,
95
+ wsAddr: this.account.wsURL,
96
+ apiAddr: this.account.apiURL,
97
+ });
98
+ } catch (err) {
99
+ const msg = err instanceof Error ? err.message : String(err);
100
+ this.setState("error", msg);
101
+ throw err;
252
102
  }
253
-
254
- // Schedule reconnection
255
- this.reconnectTimer = setTimeout(() => {
256
- if (this.shouldReconnect) {
257
- this.connect();
258
- }
259
- }, this.reconnectInterval);
260
- }
261
-
262
- /**
263
- * Start heartbeat ping interval.
264
- */
265
- private startPing(): void {
266
- this.stopPing();
267
-
268
- this.pingTimer = setInterval(() => {
269
- this.sendPing();
270
- }, this.pingInterval);
271
103
  }
272
104
 
273
- /**
274
- * Stop heartbeat ping.
275
- */
276
- private stopPing(): void {
277
- if (this.pingTimer) {
278
- clearInterval(this.pingTimer);
279
- this.pingTimer = null;
105
+ stop(): void {
106
+ this.stopped = true;
107
+ if (this.sdk) {
108
+ this.sdk.logout().catch(() => {});
109
+ this.sdk = null;
280
110
  }
111
+ this.setState("disconnected");
281
112
  }
282
113
 
283
- /**
284
- * Send ping to keep connection alive.
285
- */
286
- private sendPing(): void {
287
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
288
- try {
289
- this.ws.send(
290
- JSON.stringify({
291
- reqIdentifier: WS_REQ.HEARTBEAT,
292
- operationID: generateOperationID(),
293
- sendID: this.account.userID,
294
- sendTime: Date.now(),
295
- }),
296
- );
297
- } catch (error) {
298
- console.error("Teamily ping failed:", error);
299
- }
300
- }
114
+ getState(): TeamilyConnectionState {
115
+ return this.state;
301
116
  }
302
117
 
303
- /**
304
- * Update and notify connection state.
305
- */
306
118
  private setState(state: TeamilyConnectionState, error?: string): void {
307
119
  this.state = state;
308
120
  this.onStateChange?.(state, error);
309
121
  }
310
-
311
- /**
312
- * Get current connection state.
313
- */
314
- getState(): TeamilyConnectionState {
315
- return this.state;
316
- }
317
122
  }
318
123
 
319
- /**
320
- * Parse raw OpenIM message content into normalized internal format.
321
- * OpenIM text messages use `{ content: "text" }`, not `{ text: "..." }`.
322
- */
323
- function parseMessageContent(raw: unknown, contentType: number): TeamilyMessage["content"] {
324
- if (!raw) {
325
- return {};
124
+ // ---- SDK message conversion helpers ----
125
+
126
+ import type { MessageItem } from "@openim/client-sdk";
127
+
128
+ function convertSdkMessage(msg: MessageItem, selfUserID: string): TeamilyMessage | null {
129
+ const contentType = msg.contentType ?? CONTENT_TYPES.TEXT;
130
+ const sessionType = msg.sessionType ?? SESSION_TYPES.SINGLE;
131
+
132
+ const content = parseSdkContent(msg, contentType);
133
+
134
+ // Skip messages with no usable content
135
+ if (!content.text && !content.picture && !content.video && !content.audio) {
136
+ return null;
326
137
  }
327
138
 
328
- const obj = (typeof raw === "string" ? JSON.parse(raw) : raw) as Record<string, unknown>;
139
+ return {
140
+ serverMsgID: msg.serverMsgID || msg.clientMsgID || `${Date.now()}_${Math.random()}`,
141
+ sendID: msg.sendID || "unknown",
142
+ recvID: sessionType === SESSION_TYPES.GROUP ? (msg.groupID || "") : (msg.recvID || selfUserID),
143
+ content,
144
+ contentType,
145
+ sessionType,
146
+ sendTime: msg.sendTime || Date.now(),
147
+ };
148
+ }
329
149
 
150
+ function parseSdkContent(msg: MessageItem, contentType: number): TeamilyMessage["content"] {
330
151
  switch (contentType) {
331
- case CONTENT_TYPES.TEXT:
332
- return {
333
- text: typeof obj.content === "string" ? obj.content : String(obj.content ?? ""),
334
- };
152
+ case CONTENT_TYPES.TEXT: {
153
+ // SDK puts text in textElem.content; fallback to raw content string
154
+ const text = msg.textElem?.content ?? tryParseTextContent(msg.content);
155
+ return text ? { text } : {};
156
+ }
335
157
  case CONTENT_TYPES.PICTURE:
336
- return { picture: obj as unknown as TeamilyPictureContent };
158
+ if (msg.pictureElem?.sourcePicture) {
159
+ return { picture: msg.pictureElem as unknown as TeamilyPictureContent };
160
+ }
161
+ return {};
337
162
  case CONTENT_TYPES.VIDEO:
338
- return { video: obj as unknown as TeamilyVideoContent };
163
+ if (msg.videoElem?.videoUrl) {
164
+ return { video: msg.videoElem as unknown as TeamilyVideoContent };
165
+ }
166
+ return {};
339
167
  case CONTENT_TYPES.VOICE:
340
- return { audio: obj as unknown as TeamilyAudioContent };
168
+ if (msg.soundElem?.sourceUrl) {
169
+ return { audio: msg.soundElem as unknown as TeamilyAudioContent };
170
+ }
171
+ return {};
341
172
  default:
342
173
  return {};
343
174
  }
344
175
  }
345
176
 
346
- /**
347
- * Global monitor instances per account ID.
348
- */
177
+ /** Try to extract text from the raw JSON content string (OpenIM text format: `{"content":"..."}`) */
178
+ function tryParseTextContent(raw: string | undefined): string | undefined {
179
+ if (!raw) return undefined;
180
+ try {
181
+ const obj = JSON.parse(raw) as { content?: string };
182
+ return typeof obj.content === "string" ? obj.content : undefined;
183
+ } catch {
184
+ return raw;
185
+ }
186
+ }
187
+
188
+ // ---- Global monitor registry ----
189
+
349
190
  const monitors = new Map<string, TeamilyMonitor>();
350
191
 
351
- /**
352
- * Start monitoring for a Teamily account.
353
- */
354
192
  export function startTeamilyMonitoring(
355
193
  account: ResolvedTeamilyAccount,
356
194
  onMessage: TeamilyMessageHandler,
357
195
  onStateChange?: (state: TeamilyConnectionState, error?: string) => void,
358
196
  ): () => void {
359
- const monitor = new TeamilyMonitor({
360
- account,
361
- onMessage,
362
- onStateChange,
363
- });
364
-
197
+ const monitor = new TeamilyMonitor({ account, onMessage, onStateChange });
365
198
  monitors.set(account.accountId, monitor);
366
- monitor.start();
199
+ monitor.start().catch((err) => {
200
+ console.error(`Teamily monitor start failed for ${account.accountId}:`, err);
201
+ });
367
202
 
368
- // Return cleanup function
369
203
  return () => {
370
204
  monitor.stop();
371
205
  monitors.delete(account.accountId);
372
206
  };
373
207
  }
374
208
 
375
- /**
376
- * Stop monitoring for a Teamily account.
377
- */
378
209
  export function stopTeamilyMonitoring(accountId: string): void {
379
210
  const monitor = monitors.get(accountId);
380
211
  if (monitor) {
@@ -383,9 +214,6 @@ export function stopTeamilyMonitoring(accountId: string): void {
383
214
  }
384
215
  }
385
216
 
386
- /**
387
- * Get monitor for an account.
388
- */
389
217
  export function getTeamilyMonitor(accountId: string): TeamilyMonitor | undefined {
390
218
  return monitors.get(accountId);
391
219
  }