@ascegu/teamily 1.0.9 → 1.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascegu/teamily",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "OpenClaw Teamily channel plugin - Team instant messaging server integration",
5
5
  "keywords": [
6
6
  "channel",
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: {
@@ -268,6 +237,8 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
268
237
 
269
238
  log?.info?.(`Starting Teamily channel (account: ${accountId})`);
270
239
 
240
+ const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5 MB
241
+
271
242
  const stopFn = startTeamilyMonitoring(account, async (message) => {
272
243
  const rt = getTeamilyRuntime();
273
244
  const currentCfg = rt.config.loadConfig();
@@ -286,6 +257,26 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
286
257
  mediaUrl = message.content.audio.sourceUrl;
287
258
  }
288
259
 
260
+ // Download remote media to a local temp file so the agent recognises
261
+ // image-only messages (hasMediaAttachment checks MediaPath, not MediaUrl).
262
+ let mediaPath: string | undefined;
263
+ let mediaType: string | undefined;
264
+ if (mediaUrl) {
265
+ try {
266
+ const fetched = await rt.channel.media.fetchRemoteMedia({ url: mediaUrl, maxBytes: MEDIA_MAX_BYTES });
267
+ const saved = await rt.channel.media.saveMediaBuffer(
268
+ fetched.buffer,
269
+ fetched.contentType,
270
+ "inbound",
271
+ MEDIA_MAX_BYTES,
272
+ );
273
+ mediaPath = saved.path;
274
+ mediaType = saved.contentType;
275
+ } catch (err) {
276
+ log?.warn?.(`[${accountId}] Failed to download Teamily media: ${String(err)}`);
277
+ }
278
+ }
279
+
289
280
  const msgCtx = {
290
281
  Body: text,
291
282
  From: from,
@@ -296,6 +287,8 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
296
287
  OriginatingTo: from,
297
288
  ChatType: isGroup ? "group" : "direct",
298
289
  MediaUrl: mediaUrl,
290
+ MediaPath: mediaPath,
291
+ MediaType: mediaType,
299
292
  };
300
293
 
301
294
  await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
@@ -305,8 +298,10 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
305
298
  deliver: async (payload: { text?: string; body?: string }) => {
306
299
  const replyText = payload?.text ?? payload?.body;
307
300
  if (replyText) {
301
+ const monitor = getTeamilyMonitor(accountId);
302
+ if (!monitor) throw new Error(`Teamily monitor not running for account ${accountId}`);
308
303
  const target = normalizeTeamilyTarget(from);
309
- await sendMessageTeamily({ account, target, text: replyText });
304
+ await monitor.sendText(target, replyText);
310
305
  }
311
306
  },
312
307
  onReplyStart: () => {
@@ -369,3 +364,12 @@ function applyTeamilyAccountConfig(params: {
369
364
  },
370
365
  } as CoreConfig;
371
366
  }
367
+
368
+ function requireMonitor(accountId?: string | null) {
369
+ const id = accountId || "default";
370
+ const monitor = getTeamilyMonitor(id);
371
+ if (!monitor) {
372
+ throw new Error(`Teamily gateway not running for account "${id}" — outbound requires an active gateway`);
373
+ }
374
+ return monitor;
375
+ }
package/src/monitor.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ResolvedTeamilyAccount,
3
3
  TeamilyMessage,
4
+ TeamilyMessageTarget,
4
5
  TeamilyPictureContent,
5
6
  TeamilyVideoContent,
6
7
  TeamilyAudioContent,
@@ -32,7 +33,8 @@ async function loadSDK() {
32
33
  * Monitor for incoming Teamily messages using @openim/client-sdk.
33
34
  *
34
35
  * Delegates WebSocket connection, authentication, heartbeat, and
35
- * reconnection to the official OpenIM SDK.
36
+ * reconnection to the official OpenIM SDK. Also exposes send methods
37
+ * so outbound replies flow through the same WebSocket connection.
36
38
  */
37
39
  export class TeamilyMonitor {
38
40
  private account: ResolvedTeamilyAccount;
@@ -56,7 +58,6 @@ export class TeamilyMonitor {
56
58
  const sdk = getSDK();
57
59
  this.sdk = sdk;
58
60
 
59
- // Connection events
60
61
  sdk.on(CbEvents.OnConnecting, () => {
61
62
  if (!this.stopped) this.setState("connecting");
62
63
  });
@@ -73,15 +74,12 @@ export class TeamilyMonitor {
73
74
  if (!this.stopped) this.setState("error", "Token expired");
74
75
  });
75
76
 
76
- // Incoming messages
77
77
  sdk.on(CbEvents.OnRecvNewMessages, ({ data }) => {
78
78
  if (this.stopped || !data) return;
79
79
  for (const msg of data) {
80
- // Skip self-sent messages
81
80
  if (msg.sendID === this.account.userID) continue;
82
81
  const converted = convertSdkMessage(msg, this.account.userID);
83
82
  if (converted) {
84
- // Fire-and-forget; errors are logged by the gateway dispatcher
85
83
  void this.onMessage(converted);
86
84
  }
87
85
  }
@@ -115,6 +113,105 @@ export class TeamilyMonitor {
115
113
  return this.state;
116
114
  }
117
115
 
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 || "";
127
+ }
128
+
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 || "";
145
+ }
146
+
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
+ }
171
+
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 || "";
188
+ }
189
+
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 || "";
206
+ }
207
+
208
+ private requireSdk(): SdkInstance {
209
+ if (!this.sdk) {
210
+ throw new Error("Teamily SDK not connected");
211
+ }
212
+ return this.sdk;
213
+ }
214
+
118
215
  private setState(state: TeamilyConnectionState, error?: string): void {
119
216
  this.state = state;
120
217
  this.onStateChange?.(state, error);
@@ -131,7 +228,6 @@ function convertSdkMessage(msg: MessageItem, selfUserID: string): TeamilyMessage
131
228
 
132
229
  const content = parseSdkContent(msg, contentType);
133
230
 
134
- // Skip messages with no usable content
135
231
  if (!content.text && !content.picture && !content.video && !content.audio) {
136
232
  return null;
137
233
  }
@@ -150,7 +246,6 @@ function convertSdkMessage(msg: MessageItem, selfUserID: string): TeamilyMessage
150
246
  function parseSdkContent(msg: MessageItem, contentType: number): TeamilyMessage["content"] {
151
247
  switch (contentType) {
152
248
  case CONTENT_TYPES.TEXT: {
153
- // SDK puts text in textElem.content; fallback to raw content string
154
249
  const text = msg.textElem?.content ?? tryParseTextContent(msg.content);
155
250
  return text ? { text } : {};
156
251
  }
@@ -174,7 +269,6 @@ function parseSdkContent(msg: MessageItem, contentType: number): TeamilyMessage[
174
269
  }
175
270
  }
176
271
 
177
- /** Try to extract text from the raw JSON content string (OpenIM text format: `{"content":"..."}`) */
178
272
  function tryParseTextContent(raw: string | undefined): string | undefined {
179
273
  if (!raw) return undefined;
180
274
  try {