@ascegu/teamily 1.0.22 → 1.0.23

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.22",
3
+ "version": "1.0.23",
4
4
  "description": "OpenClaw Teamily channel plugin - Team instant messaging server integration",
5
5
  "keywords": [
6
6
  "channel",
package/src/channel.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import {
2
3
  applyAccountNameToChannelSection,
3
4
  buildChannelConfigSchema,
@@ -14,6 +15,9 @@ import {
14
15
  type ChannelOutboundContext,
15
16
  type ChannelStatusIssue,
16
17
  type HistoryEntry,
18
+ loadOutboundMediaFromUrl,
19
+ resolveOutboundMediaUrls,
20
+ sendMediaWithLeadingCaption,
17
21
  } from "openclaw/plugin-sdk";
18
22
  import {
19
23
  buildAccountScopedDmSecurityPolicy,
@@ -26,12 +30,13 @@ import {
26
30
  } from "./accounts.js";
27
31
  import { TeamilyConfigSchema } from "./config-schema.js";
28
32
  import type { CoreConfig } from "./config-schema.js";
29
- import { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring } from "./monitor.js";
33
+ import { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring, type TeamilyMonitor } from "./monitor.js";
30
34
  import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
31
35
  import { probeTeamily } from "./probe.js";
32
36
  import { getTeamilyRuntime } from "./runtime.js";
33
37
  import type { ResolvedTeamilyAccount } from "./types.js";
34
38
  import { isGroupSession } from "./types.js";
39
+ import { detectMediaCategory, guessContentType, isLocalMediaPath } from "./upload.js";
35
40
 
36
41
  const meta = {
37
42
  id: "teamily",
@@ -178,27 +183,22 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
178
183
  const monitor = requireMonitor(accountId);
179
184
  const target = normalizeTeamilyTarget(to);
180
185
 
181
- let messageId: string;
182
- const urlLower = mediaUrl.toLowerCase();
183
- if (urlLower.endsWith(".mp4") || urlLower.endsWith(".mov") || urlLower.endsWith(".webm")) {
184
- messageId = await monitor.sendVideo(target, mediaUrl);
185
- } else if (
186
- urlLower.endsWith(".mp3") ||
187
- urlLower.endsWith(".m4a") ||
188
- urlLower.endsWith(".wav")
189
- ) {
190
- messageId = await monitor.sendAudio(target, mediaUrl);
191
- } else if (
192
- urlLower.endsWith(".pdf") ||
193
- urlLower.endsWith(".doc") ||
194
- urlLower.endsWith(".docx") ||
195
- urlLower.endsWith(".zip")
196
- ) {
197
- messageId = await monitor.sendFile(target, mediaUrl);
198
- } else {
199
- messageId = await monitor.sendImage(target, mediaUrl);
186
+ // Local file path: load buffer and send via SDK's *ByFile methods
187
+ // (SDK handles the upload internally, like Telegram's InputFile).
188
+ if (isLocalMediaPath(mediaUrl)) {
189
+ const media = await loadOutboundMediaFromUrl(mediaUrl, {
190
+ mediaLocalRoots: ctx.mediaLocalRoots,
191
+ });
192
+ const fileName = media.fileName || path.basename(mediaUrl) || "media";
193
+ const contentType = media.contentType || guessContentType(mediaUrl);
194
+ const messageId = await sendMediaBuffer(
195
+ monitor, target, media.buffer, fileName, contentType, mediaUrl,
196
+ );
197
+ return { channel: "teamily" as const, messageId };
200
198
  }
201
199
 
200
+ // Remote URL: use existing *ByURL methods.
201
+ const messageId = await sendMediaByUrl(monitor, target, mediaUrl);
202
202
  return { channel: "teamily" as const, messageId };
203
203
  },
204
204
  },
@@ -368,16 +368,37 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
368
368
  ctx: msgCtx,
369
369
  cfg: currentCfg,
370
370
  dispatcherOptions: {
371
- deliver: async (payload: { text?: string; body?: string }) => {
372
- const replyText = payload?.text ?? payload?.body;
373
- if (replyText) {
374
- const monitor = getTeamilyMonitor(accountId);
375
- if (!monitor)
376
- throw new Error(`Teamily monitor not running for account ${accountId}`);
371
+ deliver: async (payload) => {
372
+ const monitor = getTeamilyMonitor(accountId);
373
+ if (!monitor)
374
+ throw new Error(`Teamily monitor not running for account ${accountId}`);
375
+ const target = normalizeTeamilyTarget(replyTarget);
376
+ const replyText = (payload as Record<string, unknown>)?.text as string | undefined
377
+ ?? (payload as Record<string, unknown>)?.body as string | undefined
378
+ ?? "";
379
+
380
+ // Send media attachments (first with caption, rest without).
381
+ const mediaUrls = resolveOutboundMediaUrls(payload as Record<string, unknown>);
382
+ const sentMedia = await sendMediaWithLeadingCaption({
383
+ mediaUrls,
384
+ caption: replyText,
385
+ send: async ({ mediaUrl: url, caption }) => {
386
+ await loadAndSendMedia(monitor, target, url);
387
+ // OpenIM doesn't support inline captions on media; send separately.
388
+ if (caption) {
389
+ await monitor.sendText(target, caption);
390
+ }
391
+ },
392
+ onError: (error) => {
393
+ log?.warn?.(`[${accountId}] Media send failed: ${String(error)}`);
394
+ },
395
+ });
396
+
397
+ // If no media was sent, send text only.
398
+ if (!sentMedia && replyText) {
377
399
  log?.info?.(
378
400
  `[${accountId}] Sending reply to: ${replyTarget} (isGroup=${isGroup})`,
379
401
  );
380
- const target = normalizeTeamilyTarget(replyTarget);
381
402
  await monitor.sendText(target, replyText);
382
403
  }
383
404
  },
@@ -457,6 +478,66 @@ function applyTeamilyAccountConfig(params: {
457
478
  } as CoreConfig;
458
479
  }
459
480
 
481
+ /** Send a local media buffer using SDK's *ByFile methods (SDK handles upload). */
482
+ async function sendMediaBuffer(
483
+ monitor: TeamilyMonitor,
484
+ target: ReturnType<typeof normalizeTeamilyTarget>,
485
+ buffer: Buffer,
486
+ fileName: string,
487
+ contentType: string,
488
+ originalPath: string,
489
+ ): Promise<string> {
490
+ const category = detectMediaCategory(originalPath);
491
+ switch (category) {
492
+ case "video":
493
+ return monitor.sendVideoBuffer(target, buffer, fileName, contentType);
494
+ case "audio":
495
+ return monitor.sendAudioBuffer(target, buffer, fileName, contentType);
496
+ case "file":
497
+ return monitor.sendFileBuffer(target, buffer, fileName, contentType);
498
+ default:
499
+ return monitor.sendImageBuffer(target, buffer, fileName, contentType);
500
+ }
501
+ }
502
+
503
+ /** Send media by remote URL using existing *ByURL methods. */
504
+ async function sendMediaByUrl(
505
+ monitor: TeamilyMonitor,
506
+ target: ReturnType<typeof normalizeTeamilyTarget>,
507
+ url: string,
508
+ ): Promise<string> {
509
+ const category = detectMediaCategory(url);
510
+ switch (category) {
511
+ case "video":
512
+ return monitor.sendVideo(target, url);
513
+ case "audio":
514
+ return monitor.sendAudio(target, url);
515
+ case "file":
516
+ return monitor.sendFile(target, url);
517
+ default:
518
+ return monitor.sendImage(target, url);
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Load a local media file and send it via SDK buffer upload.
524
+ * For remote URLs, send directly via *ByURL methods.
525
+ */
526
+ async function loadAndSendMedia(
527
+ monitor: TeamilyMonitor,
528
+ target: ReturnType<typeof normalizeTeamilyTarget>,
529
+ mediaUrl: string,
530
+ ): Promise<void> {
531
+ if (isLocalMediaPath(mediaUrl)) {
532
+ const media = await loadOutboundMediaFromUrl(mediaUrl);
533
+ const fileName = media.fileName || path.basename(mediaUrl) || "media";
534
+ const contentType = media.contentType || guessContentType(mediaUrl);
535
+ await sendMediaBuffer(monitor, target, media.buffer, fileName, contentType, mediaUrl);
536
+ } else {
537
+ await sendMediaByUrl(monitor, target, mediaUrl);
538
+ }
539
+ }
540
+
460
541
  function requireMonitor(accountId?: string | null) {
461
542
  const id = accountId || "default";
462
543
  const monitor = getTeamilyMonitor(id);
package/src/monitor.ts CHANGED
@@ -36,11 +36,6 @@ async function loadSDK() {
36
36
  * reconnection to the official OpenIM SDK. Also exposes send methods
37
37
  * so outbound replies flow through the same WebSocket connection.
38
38
  */
39
- /** Returns true when the string looks like an HTTP(S) URL rather than a local file path. */
40
- function isHttpUrl(s: string): boolean {
41
- return s.startsWith("http://") || s.startsWith("https://");
42
- }
43
-
44
39
  export class TeamilyMonitor {
45
40
  private account: ResolvedTeamilyAccount;
46
41
  private onMessage: TeamilyMessageHandler;
@@ -131,29 +126,9 @@ export class TeamilyMonitor {
131
126
  return result.data?.serverMsgID || result.data?.clientMsgID || "";
132
127
  }
133
128
 
134
- /**
135
- * Upload a local file to the OpenIM server and return its download URL.
136
- * Uses the SDK's built-in uploadFile which handles multipart upload to
137
- * the server's object storage. Requires Node.js 20+ for global File.
138
- */
139
- async uploadLocalFile(localPath: string, contentType?: string): Promise<string> {
140
- const sdk = this.requireSdk();
141
- const { readFileSync } = await import("node:fs");
142
- const { basename, extname } = await import("node:path");
143
- const buffer = readFileSync(localPath);
144
- const fileName = basename(localPath);
145
- const mime = contentType || guessMimeType(extname(localPath));
146
- const file = new File([buffer], fileName, { type: mime });
147
- const result = await sdk.uploadFile({ file, name: fileName, contentType: mime });
148
- const url = result.data?.url;
149
- if (!url) throw new Error(`Upload failed for ${localPath}: no URL returned`);
150
- return url;
151
- }
152
-
153
129
  /** Send an image message through the SDK WebSocket connection. */
154
- async sendImage(target: TeamilyMessageTarget, urlOrPath: string): Promise<string> {
130
+ async sendImage(target: TeamilyMessageTarget, url: string): Promise<string> {
155
131
  const sdk = this.requireSdk();
156
- const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
157
132
  const picInfo = { uuid: "", type: "", width: 0, height: 0, size: 0, url };
158
133
  const created = await sdk.createImageMessageByURL({
159
134
  sourcePicture: picInfo,
@@ -170,9 +145,8 @@ export class TeamilyMonitor {
170
145
  }
171
146
 
172
147
  /** Send a video message through the SDK WebSocket connection. */
173
- async sendVideo(target: TeamilyMessageTarget, urlOrPath: string): Promise<string> {
148
+ async sendVideo(target: TeamilyMessageTarget, url: string): Promise<string> {
174
149
  const sdk = this.requireSdk();
175
- const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
176
150
  const created = await sdk.createVideoMessageByURL({
177
151
  videoPath: "",
178
152
  duration: 0,
@@ -196,9 +170,8 @@ export class TeamilyMonitor {
196
170
  }
197
171
 
198
172
  /** Send a sound/audio message through the SDK WebSocket connection. */
199
- async sendAudio(target: TeamilyMessageTarget, urlOrPath: string): Promise<string> {
173
+ async sendAudio(target: TeamilyMessageTarget, url: string): Promise<string> {
200
174
  const sdk = this.requireSdk();
201
- const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
202
175
  const created = await sdk.createSoundMessageByURL({
203
176
  uuid: "",
204
177
  soundPath: "",
@@ -215,12 +188,11 @@ export class TeamilyMonitor {
215
188
  }
216
189
 
217
190
  /** Send a file message through the SDK WebSocket connection. */
218
- async sendFile(target: TeamilyMessageTarget, urlOrPath: string, fileName?: string): Promise<string> {
191
+ async sendFile(target: TeamilyMessageTarget, url: string, fileName?: string): Promise<string> {
219
192
  const sdk = this.requireSdk();
220
- const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
221
193
  const created = await sdk.createFileMessageByURL({
222
194
  filePath: "",
223
- fileName: fileName || urlOrPath.split("/").pop() || "file",
195
+ fileName: fileName || url.split("/").pop() || "file",
224
196
  uuid: "",
225
197
  sourceUrl: url,
226
198
  fileSize: 0,
@@ -233,6 +205,99 @@ export class TeamilyMonitor {
233
205
  return result.data?.serverMsgID || result.data?.clientMsgID || "";
234
206
  }
235
207
 
208
+ // ---- Buffer-based send methods (local file → SDK upload) ----
209
+ // Like Telegram's InputFile(buffer), pass a buffer and let the SDK upload.
210
+
211
+ /** Send an image from a local buffer. SDK handles the upload to object storage. */
212
+ async sendImageBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
213
+ const sdk = this.requireSdk();
214
+ const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
215
+ const picInfo = { uuid: "", type: contentType, width: 0, height: 0, size: buffer.length, url: "" };
216
+ const created = await sdk.createImageMessageByFile({
217
+ sourcePicture: picInfo,
218
+ bigPicture: picInfo,
219
+ snapshotPicture: picInfo,
220
+ sourcePath: "",
221
+ file,
222
+ });
223
+ const result = await sdk.sendMessage({
224
+ recvID: target.type === "user" ? target.id : "",
225
+ groupID: target.type === "group" ? target.id : "",
226
+ message: created.data,
227
+ });
228
+ return result.data?.serverMsgID || result.data?.clientMsgID || "";
229
+ }
230
+
231
+ /** Send a video from a local buffer. SDK handles the upload to object storage. */
232
+ async sendVideoBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
233
+ const sdk = this.requireSdk();
234
+ const videoFile = new File([new Uint8Array(buffer)], fileName, { type: contentType });
235
+ // snapshotFile is required by the SDK type but we have no thumbnail; use an empty file.
236
+ const snapshotFile = new File([], "snapshot.jpg", { type: "image/jpeg" });
237
+ const created = await sdk.createVideoMessageByFile({
238
+ videoPath: "",
239
+ duration: 0,
240
+ videoType: contentType,
241
+ snapshotPath: "",
242
+ videoUUID: "",
243
+ videoUrl: "",
244
+ videoSize: buffer.length,
245
+ snapshotUUID: "",
246
+ snapshotSize: 0,
247
+ snapshotUrl: "",
248
+ snapshotWidth: 0,
249
+ snapshotHeight: 0,
250
+ videoFile,
251
+ snapshotFile,
252
+ });
253
+ const result = await sdk.sendMessage({
254
+ recvID: target.type === "user" ? target.id : "",
255
+ groupID: target.type === "group" ? target.id : "",
256
+ message: created.data,
257
+ });
258
+ return result.data?.serverMsgID || result.data?.clientMsgID || "";
259
+ }
260
+
261
+ /** Send an audio/sound from a local buffer. SDK handles the upload to object storage. */
262
+ async sendAudioBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
263
+ const sdk = this.requireSdk();
264
+ const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
265
+ const created = await sdk.createSoundMessageByFile({
266
+ uuid: "",
267
+ soundPath: "",
268
+ sourceUrl: "",
269
+ dataSize: buffer.length,
270
+ duration: 0,
271
+ file,
272
+ });
273
+ const result = await sdk.sendMessage({
274
+ recvID: target.type === "user" ? target.id : "",
275
+ groupID: target.type === "group" ? target.id : "",
276
+ message: created.data,
277
+ });
278
+ return result.data?.serverMsgID || result.data?.clientMsgID || "";
279
+ }
280
+
281
+ /** Send a document/file from a local buffer. SDK handles the upload to object storage. */
282
+ async sendFileBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
283
+ const sdk = this.requireSdk();
284
+ const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
285
+ const created = await sdk.createFileMessageByFile({
286
+ filePath: "",
287
+ fileName,
288
+ uuid: "",
289
+ sourceUrl: "",
290
+ fileSize: buffer.length,
291
+ file,
292
+ });
293
+ const result = await sdk.sendMessage({
294
+ recvID: target.type === "user" ? target.id : "",
295
+ groupID: target.type === "group" ? target.id : "",
296
+ message: created.data,
297
+ });
298
+ return result.data?.serverMsgID || result.data?.clientMsgID || "";
299
+ }
300
+
236
301
  private requireSdk(): SdkInstance {
237
302
  if (!this.sdk) {
238
303
  throw new Error("Teamily SDK not connected");
@@ -246,32 +311,6 @@ export class TeamilyMonitor {
246
311
  }
247
312
  }
248
313
 
249
- // ---- MIME type helper ----
250
-
251
- const MIME_MAP: Record<string, string> = {
252
- ".jpg": "image/jpeg",
253
- ".jpeg": "image/jpeg",
254
- ".png": "image/png",
255
- ".gif": "image/gif",
256
- ".webp": "image/webp",
257
- ".bmp": "image/bmp",
258
- ".mp4": "video/mp4",
259
- ".mov": "video/quicktime",
260
- ".webm": "video/webm",
261
- ".mp3": "audio/mpeg",
262
- ".m4a": "audio/mp4",
263
- ".wav": "audio/wav",
264
- ".ogg": "audio/ogg",
265
- ".pdf": "application/pdf",
266
- ".doc": "application/msword",
267
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
268
- ".zip": "application/zip",
269
- };
270
-
271
- function guessMimeType(ext: string): string {
272
- return MIME_MAP[ext.toLowerCase()] || "application/octet-stream";
273
- }
274
-
275
314
  // ---- SDK message conversion helpers ----
276
315
 
277
316
  import type { MessageItem } from "@openim/client-sdk";
package/src/upload.ts ADDED
@@ -0,0 +1,46 @@
1
+ import path from "node:path";
2
+
3
+ /** Returns true if the media URL is a local file path rather than a remote URL. */
4
+ export function isLocalMediaPath(mediaUrl: string): boolean {
5
+ return !(/^https?:\/\//i.test(mediaUrl));
6
+ }
7
+
8
+ /** Detect the media category from a file extension. */
9
+ export type MediaCategory = "image" | "video" | "audio" | "file";
10
+
11
+ export function detectMediaCategory(filePath: string): MediaCategory {
12
+ const ext = path.extname(filePath).toLowerCase();
13
+ if ([".mp4", ".mov", ".webm"].includes(ext)) return "video";
14
+ if ([".mp3", ".m4a", ".wav", ".ogg"].includes(ext)) return "audio";
15
+ if ([".pdf", ".doc", ".docx", ".zip"].includes(ext)) return "file";
16
+ return "image";
17
+ }
18
+
19
+ /**
20
+ * Guess a reasonable MIME content type from a file extension.
21
+ * Falls back to application/octet-stream for unknown types.
22
+ */
23
+ export function guessContentType(filePath: string): string {
24
+ const ext = path.extname(filePath).toLowerCase();
25
+ const map: Record<string, string> = {
26
+ ".png": "image/png",
27
+ ".jpg": "image/jpeg",
28
+ ".jpeg": "image/jpeg",
29
+ ".gif": "image/gif",
30
+ ".webp": "image/webp",
31
+ ".bmp": "image/bmp",
32
+ ".svg": "image/svg+xml",
33
+ ".mp4": "video/mp4",
34
+ ".mov": "video/quicktime",
35
+ ".webm": "video/webm",
36
+ ".mp3": "audio/mpeg",
37
+ ".m4a": "audio/mp4",
38
+ ".wav": "audio/wav",
39
+ ".ogg": "audio/ogg",
40
+ ".pdf": "application/pdf",
41
+ ".doc": "application/msword",
42
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
43
+ ".zip": "application/zip",
44
+ };
45
+ return map[ext] ?? "application/octet-stream";
46
+ }