@ascegu/teamily 1.0.8 → 1.0.10

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 (3) hide show
  1. package/package.json +2 -1
  2. package/src/channel.ts +35 -55
  3. package/src/monitor.ts +211 -289
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascegu/teamily",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
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/channel.ts CHANGED
@@ -21,11 +21,10 @@ import {
21
21
  } from "./accounts.js";
22
22
  import { TeamilyConfigSchema } from "./config-schema.js";
23
23
  import type { CoreConfig } from "./config-schema.js";
24
- import { startTeamilyMonitoring, stopTeamilyMonitoring } from "./monitor.js";
24
+ import { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring } from "./monitor.js";
25
25
  import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
26
26
  import { probeTeamily } from "./probe.js";
27
27
  import { getTeamilyRuntime } from "./runtime.js";
28
- import { sendMessageTeamily, sendMediaTeamily } from "./send.js";
29
28
  import type { ResolvedTeamilyAccount } from "./types.js";
30
29
  import { SESSION_TYPES } from "./types.js";
31
30
 
@@ -89,13 +88,12 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
89
88
  notifyApproval: async ({ id, cfg }) => {
90
89
  try {
91
90
  const accountId = resolveDefaultTeamilyAccountId(cfg as CoreConfig);
92
- const account = resolveTeamilyAccount(cfg as CoreConfig, accountId);
93
91
  const target = normalizeTeamilyTarget(id);
94
- await sendMessageTeamily({
95
- account,
96
- target,
97
- text: PAIRING_APPROVED_MESSAGE,
98
- });
92
+ const monitor = getTeamilyMonitor(accountId);
93
+ if (monitor) {
94
+ await monitor.sendText(target, PAIRING_APPROVED_MESSAGE);
95
+ }
96
+ // If monitor isn't running, skip silently — pairing was still approved
99
97
  } catch {
100
98
  // Silently fail on notification
101
99
  }
@@ -152,71 +150,42 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
152
150
  }
153
151
  try {
154
152
  const target = normalizeTeamilyTarget(to);
155
- return { ok: true, to: target.id };
153
+ // Preserve the full target format so sendText/sendMedia can distinguish user vs group
154
+ const resolved = target.type === "group" ? `group:${target.id}` : target.id;
155
+ return { ok: true, to: resolved };
156
156
  } catch (err) {
157
157
  return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
158
158
  }
159
159
  },
160
160
  sendText: async (ctx: ChannelOutboundContext) => {
161
161
  const { to, text, accountId } = ctx;
162
- const account = resolveTeamilyAccount(ctx.cfg as CoreConfig, accountId);
162
+ const monitor = requireMonitor(accountId);
163
163
  const target = normalizeTeamilyTarget(to);
164
-
165
- const result = await sendMessageTeamily({
166
- account,
167
- target,
168
- text,
169
- replyToId: ctx.replyToId || undefined,
170
- });
171
-
172
- if (!result.success) {
173
- throw new Error(result.error || "Failed to send message");
174
- }
175
-
176
- return { channel: "teamily" as const, messageId: result.messageId ?? "" };
164
+ const messageId = await monitor.sendText(target, text);
165
+ return { channel: "teamily" as const, messageId };
177
166
  },
178
167
  sendMedia: async (ctx: ChannelOutboundContext) => {
179
- const { to, text, accountId } = ctx;
168
+ const { to, accountId } = ctx;
180
169
  const mediaUrl = ctx.mediaUrl;
181
170
  if (!mediaUrl) {
182
171
  throw new Error("Media URL is required");
183
172
  }
184
- const account = resolveTeamilyAccount(ctx.cfg as CoreConfig, accountId);
173
+ const monitor = requireMonitor(accountId);
185
174
  const target = normalizeTeamilyTarget(to);
186
175
 
187
- // Determine media type from URL or assume image
188
- let mediaType: "image" | "video" | "audio" | "file" = "image";
176
+ let messageId: string;
189
177
  const urlLower = mediaUrl.toLowerCase();
190
178
  if (urlLower.endsWith(".mp4") || urlLower.endsWith(".mov") || urlLower.endsWith(".webm")) {
191
- mediaType = "video";
192
- } else if (
193
- urlLower.endsWith(".mp3") ||
194
- urlLower.endsWith(".m4a") ||
195
- urlLower.endsWith(".wav")
196
- ) {
197
- mediaType = "audio";
198
- } else if (
199
- urlLower.endsWith(".pdf") ||
200
- urlLower.endsWith(".doc") ||
201
- urlLower.endsWith(".docx") ||
202
- urlLower.endsWith(".zip")
203
- ) {
204
- mediaType = "file";
179
+ messageId = await monitor.sendVideo(target, mediaUrl);
180
+ } else if (urlLower.endsWith(".mp3") || urlLower.endsWith(".m4a") || urlLower.endsWith(".wav")) {
181
+ messageId = await monitor.sendAudio(target, mediaUrl);
182
+ } else if (urlLower.endsWith(".pdf") || urlLower.endsWith(".doc") || urlLower.endsWith(".docx") || urlLower.endsWith(".zip")) {
183
+ messageId = await monitor.sendFile(target, mediaUrl);
184
+ } else {
185
+ messageId = await monitor.sendImage(target, mediaUrl);
205
186
  }
206
187
 
207
- const result = await sendMediaTeamily({
208
- account,
209
- target,
210
- mediaUrl,
211
- mediaType,
212
- caption: text,
213
- });
214
-
215
- if (!result.success) {
216
- throw new Error(result.error || "Failed to send media");
217
- }
218
-
219
- return { channel: "teamily" as const, messageId: result.messageId ?? "" };
188
+ return { channel: "teamily" as const, messageId };
220
189
  },
221
190
  },
222
191
  status: {
@@ -305,8 +274,10 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
305
274
  deliver: async (payload: { text?: string; body?: string }) => {
306
275
  const replyText = payload?.text ?? payload?.body;
307
276
  if (replyText) {
277
+ const monitor = getTeamilyMonitor(accountId);
278
+ if (!monitor) throw new Error(`Teamily monitor not running for account ${accountId}`);
308
279
  const target = normalizeTeamilyTarget(from);
309
- await sendMessageTeamily({ account, target, text: replyText });
280
+ await monitor.sendText(target, replyText);
310
281
  }
311
282
  },
312
283
  onReplyStart: () => {
@@ -369,3 +340,12 @@ function applyTeamilyAccountConfig(params: {
369
340
  },
370
341
  } as CoreConfig;
371
342
  }
343
+
344
+ function requireMonitor(accountId?: string | null) {
345
+ const id = accountId || "default";
346
+ const monitor = getTeamilyMonitor(id);
347
+ if (!monitor) {
348
+ throw new Error(`Teamily gateway not running for account "${id}" — outbound requires an active gateway`);
349
+ }
350
+ return monitor;
351
+ }
package/src/monitor.ts CHANGED
@@ -1,18 +1,13 @@
1
- import { generateOperationID } from "./probe.js";
2
1
  import type {
3
2
  ResolvedTeamilyAccount,
4
3
  TeamilyMessage,
4
+ TeamilyMessageTarget,
5
5
  TeamilyPictureContent,
6
6
  TeamilyVideoContent,
7
7
  TeamilyAudioContent,
8
8
  } from "./types.js";
9
9
  import { CONTENT_TYPES, SESSION_TYPES } from "./types.js";
10
10
 
11
- const WS_REQ = {
12
- LOGIN: 1001,
13
- HEARTBEAT: 1002,
14
- } as const;
15
-
16
11
  export type TeamilyMessageHandler = (message: TeamilyMessage) => Promise<void> | void;
17
12
  export type TeamilyConnectionState = "connecting" | "connected" | "disconnected" | "error";
18
13
 
@@ -20,361 +15,291 @@ export interface TeamilyMonitorOptions {
20
15
  account: ResolvedTeamilyAccount;
21
16
  onMessage: TeamilyMessageHandler;
22
17
  onStateChange?: (state: TeamilyConnectionState, error?: string) => void;
23
- reconnectInterval?: number;
24
- pingInterval?: number;
25
- websocketImpl?: typeof WebSocket;
18
+ }
19
+
20
+ type SdkModule = typeof import("@openim/client-sdk");
21
+ type SdkInstance = ReturnType<SdkModule["getSDK"]>;
22
+
23
+ // Lazy-loaded SDK to avoid top-level dynamic import issues
24
+ let sdkModule: SdkModule | null = null;
25
+ async function loadSDK() {
26
+ if (!sdkModule) {
27
+ sdkModule = await import("@openim/client-sdk");
28
+ }
29
+ return sdkModule;
26
30
  }
27
31
 
28
32
  /**
29
- * Monitor for incoming Teamily messages via WebSocket.
33
+ * Monitor for incoming Teamily messages using @openim/client-sdk.
30
34
  *
31
- * This class manages a WebSocket connection to the Teamily server
32
- * and handles incoming messages, reconnections, and heartbeat pings.
35
+ * Delegates WebSocket connection, authentication, heartbeat, and
36
+ * reconnection to the official OpenIM SDK. Also exposes send methods
37
+ * so outbound replies flow through the same WebSocket connection.
33
38
  */
34
39
  export class TeamilyMonitor {
35
40
  private account: ResolvedTeamilyAccount;
36
41
  private onMessage: TeamilyMessageHandler;
37
42
  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;
43
+ private sdk: SdkInstance | null = null;
43
44
  private state: TeamilyConnectionState = "disconnected";
44
- private shouldReconnect = true;
45
- private authenticated = false;
46
- private wsImpl: typeof WebSocket;
45
+ private stopped = false;
47
46
 
48
47
  constructor(options: TeamilyMonitorOptions) {
49
48
  this.account = options.account;
50
49
  this.onMessage = options.onMessage;
51
50
  this.onStateChange = options.onStateChange;
52
- this.reconnectInterval = options.reconnectInterval ?? 5000;
53
- this.pingInterval = options.pingInterval ?? 30000;
54
- this.wsImpl = options.websocketImpl ?? WebSocket;
55
51
  }
56
52
 
57
- /**
58
- * Start monitoring for messages.
59
- */
60
53
  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
-
54
+ this.stopped = false;
107
55
  this.setState("connecting");
108
56
 
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");
57
+ const { getSDK, CbEvents } = await loadSDK();
58
+ const sdk = getSDK();
59
+ this.sdk = sdk;
60
+
61
+ sdk.on(CbEvents.OnConnecting, () => {
62
+ if (!this.stopped) this.setState("connecting");
63
+ });
64
+ sdk.on(CbEvents.OnConnectSuccess, () => {
65
+ if (!this.stopped) this.setState("connected");
66
+ });
67
+ sdk.on(CbEvents.OnConnectFailed, ({ errCode, errMsg }) => {
68
+ if (!this.stopped) this.setState("error", `[${errCode}] ${errMsg}`);
69
+ });
70
+ sdk.on(CbEvents.OnKickedOffline, () => {
71
+ if (!this.stopped) this.setState("error", "Kicked offline");
72
+ });
73
+ sdk.on(CbEvents.OnUserTokenExpired, () => {
74
+ if (!this.stopped) this.setState("error", "Token expired");
75
+ });
76
+
77
+ sdk.on(CbEvents.OnRecvNewMessages, ({ data }) => {
78
+ if (this.stopped || !data) return;
79
+ for (const msg of data) {
80
+ if (msg.sendID === this.account.userID) continue;
81
+ const converted = convertSdkMessage(msg, this.account.userID);
82
+ if (converted) {
83
+ void this.onMessage(converted);
84
+ }
85
+ }
86
+ });
114
87
 
115
88
  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);
89
+ await sdk.login({
90
+ userID: this.account.userID,
91
+ token: this.account.token,
92
+ platformID: 5,
93
+ wsAddr: this.account.wsURL,
94
+ apiAddr: this.account.apiURL,
95
+ });
96
+ } catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ this.setState("error", msg);
99
+ throw err;
125
100
  }
126
101
  }
127
102
 
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);
103
+ stop(): void {
104
+ this.stopped = true;
105
+ if (this.sdk) {
106
+ this.sdk.logout().catch(() => {});
107
+ this.sdk = null;
154
108
  }
109
+ this.setState("disconnected");
155
110
  }
156
111
 
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();
185
- }
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
- }
197
-
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
- }
112
+ getState(): TeamilyConnectionState {
113
+ return this.state;
214
114
  }
215
115
 
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
- }
116
+ /** Send a text message through the SDK WebSocket connection. */
117
+ async sendText(target: TeamilyMessageTarget, text: string): Promise<string> {
118
+ const sdk = this.requireSdk();
119
+ const created = await sdk.createTextMessage(text);
120
+ const message = created.data;
121
+ const result = await sdk.sendMessage({
122
+ recvID: target.type === "user" ? target.id : "",
123
+ groupID: target.type === "group" ? target.id : "",
124
+ message,
125
+ });
126
+ return result.data?.serverMsgID || result.data?.clientMsgID || "";
241
127
  }
242
128
 
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;
252
- }
253
-
254
- // Schedule reconnection
255
- this.reconnectTimer = setTimeout(() => {
256
- if (this.shouldReconnect) {
257
- this.connect();
258
- }
259
- }, this.reconnectInterval);
129
+ /** Send an image message through the SDK WebSocket connection. */
130
+ async sendImage(target: TeamilyMessageTarget, url: string): Promise<string> {
131
+ const sdk = this.requireSdk();
132
+ const picInfo = { uuid: "", type: "", width: 0, height: 0, size: 0, url };
133
+ const created = await sdk.createImageMessageByURL({
134
+ sourcePicture: picInfo,
135
+ bigPicture: picInfo,
136
+ snapshotPicture: { ...picInfo, url: "" },
137
+ sourcePath: "",
138
+ });
139
+ const result = await sdk.sendMessage({
140
+ recvID: target.type === "user" ? target.id : "",
141
+ groupID: target.type === "group" ? target.id : "",
142
+ message: created.data,
143
+ });
144
+ return result.data?.serverMsgID || result.data?.clientMsgID || "";
260
145
  }
261
146
 
262
- /**
263
- * Start heartbeat ping interval.
264
- */
265
- private startPing(): void {
266
- this.stopPing();
147
+ /** Send a video message through the SDK WebSocket connection. */
148
+ async sendVideo(target: TeamilyMessageTarget, url: string): Promise<string> {
149
+ const sdk = this.requireSdk();
150
+ const created = await sdk.createVideoMessageByURL({
151
+ videoPath: "",
152
+ duration: 0,
153
+ videoType: "mp4",
154
+ snapshotPath: "",
155
+ videoUUID: "",
156
+ videoUrl: url,
157
+ videoSize: 0,
158
+ snapshotUUID: "",
159
+ snapshotSize: 0,
160
+ snapshotUrl: "",
161
+ snapshotWidth: 0,
162
+ snapshotHeight: 0,
163
+ });
164
+ const result = await sdk.sendMessage({
165
+ recvID: target.type === "user" ? target.id : "",
166
+ groupID: target.type === "group" ? target.id : "",
167
+ message: created.data,
168
+ });
169
+ return result.data?.serverMsgID || result.data?.clientMsgID || "";
170
+ }
267
171
 
268
- this.pingTimer = setInterval(() => {
269
- this.sendPing();
270
- }, this.pingInterval);
172
+ /** Send a sound/audio message through the SDK WebSocket connection. */
173
+ async sendAudio(target: TeamilyMessageTarget, url: string): Promise<string> {
174
+ const sdk = this.requireSdk();
175
+ const created = await sdk.createSoundMessageByURL({
176
+ uuid: "",
177
+ soundPath: "",
178
+ sourceUrl: url,
179
+ dataSize: 0,
180
+ duration: 0,
181
+ });
182
+ const result = await sdk.sendMessage({
183
+ recvID: target.type === "user" ? target.id : "",
184
+ groupID: target.type === "group" ? target.id : "",
185
+ message: created.data,
186
+ });
187
+ return result.data?.serverMsgID || result.data?.clientMsgID || "";
271
188
  }
272
189
 
273
- /**
274
- * Stop heartbeat ping.
275
- */
276
- private stopPing(): void {
277
- if (this.pingTimer) {
278
- clearInterval(this.pingTimer);
279
- this.pingTimer = null;
280
- }
190
+ /** Send a file message through the SDK WebSocket connection. */
191
+ async sendFile(target: TeamilyMessageTarget, url: string, fileName?: string): Promise<string> {
192
+ const sdk = this.requireSdk();
193
+ const created = await sdk.createFileMessageByURL({
194
+ filePath: "",
195
+ fileName: fileName || url.split("/").pop() || "file",
196
+ uuid: "",
197
+ sourceUrl: url,
198
+ fileSize: 0,
199
+ });
200
+ const result = await sdk.sendMessage({
201
+ recvID: target.type === "user" ? target.id : "",
202
+ groupID: target.type === "group" ? target.id : "",
203
+ message: created.data,
204
+ });
205
+ return result.data?.serverMsgID || result.data?.clientMsgID || "";
281
206
  }
282
207
 
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
- }
208
+ private requireSdk(): SdkInstance {
209
+ if (!this.sdk) {
210
+ throw new Error("Teamily SDK not connected");
300
211
  }
212
+ return this.sdk;
301
213
  }
302
214
 
303
- /**
304
- * Update and notify connection state.
305
- */
306
215
  private setState(state: TeamilyConnectionState, error?: string): void {
307
216
  this.state = state;
308
217
  this.onStateChange?.(state, error);
309
218
  }
310
-
311
- /**
312
- * Get current connection state.
313
- */
314
- getState(): TeamilyConnectionState {
315
- return this.state;
316
- }
317
219
  }
318
220
 
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 {};
221
+ // ---- SDK message conversion helpers ----
222
+
223
+ import type { MessageItem } from "@openim/client-sdk";
224
+
225
+ function convertSdkMessage(msg: MessageItem, selfUserID: string): TeamilyMessage | null {
226
+ const contentType = msg.contentType ?? CONTENT_TYPES.TEXT;
227
+ const sessionType = msg.sessionType ?? SESSION_TYPES.SINGLE;
228
+
229
+ const content = parseSdkContent(msg, contentType);
230
+
231
+ if (!content.text && !content.picture && !content.video && !content.audio) {
232
+ return null;
326
233
  }
327
234
 
328
- const obj = (typeof raw === "string" ? JSON.parse(raw) : raw) as Record<string, unknown>;
235
+ return {
236
+ serverMsgID: msg.serverMsgID || msg.clientMsgID || `${Date.now()}_${Math.random()}`,
237
+ sendID: msg.sendID || "unknown",
238
+ recvID: sessionType === SESSION_TYPES.GROUP ? (msg.groupID || "") : (msg.recvID || selfUserID),
239
+ content,
240
+ contentType,
241
+ sessionType,
242
+ sendTime: msg.sendTime || Date.now(),
243
+ };
244
+ }
329
245
 
246
+ function parseSdkContent(msg: MessageItem, contentType: number): TeamilyMessage["content"] {
330
247
  switch (contentType) {
331
- case CONTENT_TYPES.TEXT:
332
- return {
333
- text: typeof obj.content === "string" ? obj.content : String(obj.content ?? ""),
334
- };
248
+ case CONTENT_TYPES.TEXT: {
249
+ const text = msg.textElem?.content ?? tryParseTextContent(msg.content);
250
+ return text ? { text } : {};
251
+ }
335
252
  case CONTENT_TYPES.PICTURE:
336
- return { picture: obj as unknown as TeamilyPictureContent };
253
+ if (msg.pictureElem?.sourcePicture) {
254
+ return { picture: msg.pictureElem as unknown as TeamilyPictureContent };
255
+ }
256
+ return {};
337
257
  case CONTENT_TYPES.VIDEO:
338
- return { video: obj as unknown as TeamilyVideoContent };
258
+ if (msg.videoElem?.videoUrl) {
259
+ return { video: msg.videoElem as unknown as TeamilyVideoContent };
260
+ }
261
+ return {};
339
262
  case CONTENT_TYPES.VOICE:
340
- return { audio: obj as unknown as TeamilyAudioContent };
263
+ if (msg.soundElem?.sourceUrl) {
264
+ return { audio: msg.soundElem as unknown as TeamilyAudioContent };
265
+ }
266
+ return {};
341
267
  default:
342
268
  return {};
343
269
  }
344
270
  }
345
271
 
346
- /**
347
- * Global monitor instances per account ID.
348
- */
272
+ function tryParseTextContent(raw: string | undefined): string | undefined {
273
+ if (!raw) return undefined;
274
+ try {
275
+ const obj = JSON.parse(raw) as { content?: string };
276
+ return typeof obj.content === "string" ? obj.content : undefined;
277
+ } catch {
278
+ return raw;
279
+ }
280
+ }
281
+
282
+ // ---- Global monitor registry ----
283
+
349
284
  const monitors = new Map<string, TeamilyMonitor>();
350
285
 
351
- /**
352
- * Start monitoring for a Teamily account.
353
- */
354
286
  export function startTeamilyMonitoring(
355
287
  account: ResolvedTeamilyAccount,
356
288
  onMessage: TeamilyMessageHandler,
357
289
  onStateChange?: (state: TeamilyConnectionState, error?: string) => void,
358
290
  ): () => void {
359
- const monitor = new TeamilyMonitor({
360
- account,
361
- onMessage,
362
- onStateChange,
363
- });
364
-
291
+ const monitor = new TeamilyMonitor({ account, onMessage, onStateChange });
365
292
  monitors.set(account.accountId, monitor);
366
- monitor.start();
293
+ monitor.start().catch((err) => {
294
+ console.error(`Teamily monitor start failed for ${account.accountId}:`, err);
295
+ });
367
296
 
368
- // Return cleanup function
369
297
  return () => {
370
298
  monitor.stop();
371
299
  monitors.delete(account.accountId);
372
300
  };
373
301
  }
374
302
 
375
- /**
376
- * Stop monitoring for a Teamily account.
377
- */
378
303
  export function stopTeamilyMonitoring(accountId: string): void {
379
304
  const monitor = monitors.get(accountId);
380
305
  if (monitor) {
@@ -383,9 +308,6 @@ export function stopTeamilyMonitoring(accountId: string): void {
383
308
  }
384
309
  }
385
310
 
386
- /**
387
- * Get monitor for an account.
388
- */
389
311
  export function getTeamilyMonitor(accountId: string): TeamilyMonitor | undefined {
390
312
  return monitors.get(accountId);
391
313
  }