@ascegu/teamily 1.0.19 → 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/README.md CHANGED
@@ -26,29 +26,28 @@ openclaw channel configure teamily
26
26
 
27
27
  ### Server Settings
28
28
 
29
- | Field | Description | Default |
30
- |---------------|--------------------------|---------------------------|
31
- | `platformUrl` | Teamily platform URL | `https://imserver-test.teamily.ai/im_api` |
32
- | `apiURL` | Teamily REST API URL | `https://imserver-test.teamily.ai/im_api` |
33
- | `wsURL` | Teamily WebSocket URL | `wss://imserver-test.teamily.ai/msg_gateway` |
29
+ | Field | Description | Default |
30
+ | -------- | --------------------- | -------------------------------------------- |
31
+ | `apiURL` | Teamily REST API URL | `https://imserver-test.teamily.ai/im_api` |
32
+ | `wsURL` | Teamily WebSocket URL | `wss://imserver-test.teamily.ai/msg_gateway` |
34
33
 
35
34
  ### Bot Account Settings
36
35
 
37
- | Field | Required | Description |
38
- |------------|----------|---------------------------------|
39
- | `userID` | Yes | User ID for the bot account |
40
- | `token` | Yes | User token for authentication |
41
- | `nickname` | No | Display nickname |
42
- | `faceURL` | No | Avatar URL |
36
+ | Field | Required | Description |
37
+ | ---------- | -------- | ----------------------------- |
38
+ | `userID` | Yes | User ID for the bot account |
39
+ | `token` | Yes | User token for authentication |
40
+ | `nickname` | No | Display nickname |
41
+ | `faceURL` | No | Avatar URL |
43
42
 
44
43
  ### DM Security
45
44
 
46
45
  Per-account or channel-level DM security can be configured:
47
46
 
48
- | Field | Description |
49
- |--------------------|------------------------------------------------------|
50
- | `dm.policy` | DM security policy (`pairing`, `allowlist`, `open`) |
51
- | `dm.allowFrom` | List of allowed sender IDs |
47
+ | Field | Description |
48
+ | -------------- | --------------------------------------------------- |
49
+ | `dm.policy` | DM security policy (`pairing`, `allowlist`, `open`) |
50
+ | `dm.allowFrom` | List of allowed sender IDs |
52
51
 
53
52
  ### Example Configuration
54
53
 
@@ -57,7 +56,6 @@ channels:
57
56
  teamily:
58
57
  enabled: true
59
58
  server:
60
- platformUrl: https://imserver-test.teamily.ai/im_api
61
59
  apiURL: https://imserver-test.teamily.ai/im_api
62
60
  wsURL: wss://imserver-test.teamily.ai/msg_gateway
63
61
  accounts:
@@ -91,12 +89,12 @@ openclaw message send teamily:user:userID --media /path/to/image.jpg
91
89
 
92
90
  Supported media types are auto-detected by file extension:
93
91
 
94
- | Extension | Type |
95
- |------------------------------|-------|
96
- | `.jpg`, `.png`, `.gif`, etc. | Image |
97
- | `.mp4`, `.mov`, `.webm` | Video |
98
- | `.mp3`, `.m4a`, `.wav` | Audio |
99
- | `.pdf`, `.doc`, `.docx`, `.zip` | File |
92
+ | Extension | Type |
93
+ | ------------------------------- | ----- |
94
+ | `.jpg`, `.png`, `.gif`, etc. | Image |
95
+ | `.mp4`, `.mov`, `.webm` | Video |
96
+ | `.mp3`, `.m4a`, `.wav` | Audio |
97
+ | `.pdf`, `.doc`, `.docx`, `.zip` | File |
100
98
 
101
99
  ## Group Chat Behavior
102
100
 
@@ -107,19 +105,19 @@ Supported media types are auto-detected by file extension:
107
105
 
108
106
  ## Capabilities
109
107
 
110
- | Feature | Supported |
111
- |---------------------------|-----------|
112
- | Direct messaging | Yes |
113
- | Group messaging | Yes |
114
- | Text messages | Yes |
115
- | Media (image/video/audio/file) | Yes |
116
- | @-mention gating (groups) | Yes |
117
- | WebSocket real-time monitoring | Yes |
118
- | Automatic reconnection | Yes |
119
- | Connection health probes | Yes |
120
- | Reactions | No |
121
- | Threads | No |
122
- | Polls | No |
108
+ | Feature | Supported |
109
+ | ------------------------------ | --------- |
110
+ | Direct messaging | Yes |
111
+ | Group messaging | Yes |
112
+ | Text messages | Yes |
113
+ | Media (image/video/audio/file) | Yes |
114
+ | @-mention gating (groups) | Yes |
115
+ | WebSocket real-time monitoring | Yes |
116
+ | Automatic reconnection | Yes |
117
+ | Connection health probes | Yes |
118
+ | Reactions | No |
119
+ | Threads | No |
120
+ | Polls | No |
123
121
 
124
122
  ## Architecture
125
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascegu/teamily",
3
- "version": "1.0.19",
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/accounts.ts CHANGED
@@ -38,7 +38,6 @@ export function resolveTeamilyAccount(
38
38
  return {
39
39
  accountId: targetAccountId,
40
40
  enabled: true,
41
- platformUrl: config.server.platformUrl,
42
41
  apiURL: config.server.apiURL,
43
42
  wsURL: config.server.wsURL,
44
43
  userID: account.userID,
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,18 +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 (urlLower.endsWith(".mp3") || urlLower.endsWith(".m4a") || urlLower.endsWith(".wav")) {
186
- messageId = await monitor.sendAudio(target, mediaUrl);
187
- } else if (urlLower.endsWith(".pdf") || urlLower.endsWith(".doc") || urlLower.endsWith(".docx") || urlLower.endsWith(".zip")) {
188
- messageId = await monitor.sendFile(target, mediaUrl);
189
- } else {
190
- 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 };
191
198
  }
192
199
 
200
+ // Remote URL: use existing *ByURL methods.
201
+ const messageId = await sendMediaByUrl(monitor, target, mediaUrl);
193
202
  return { channel: "teamily" as const, messageId };
194
203
  },
195
204
  },
@@ -248,140 +257,167 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
248
257
 
249
258
  const stopFn = startTeamilyMonitoring(account, async (message) => {
250
259
  try {
251
- const rt = getTeamilyRuntime();
252
- const currentCfg = rt.config.loadConfig();
260
+ const rt = getTeamilyRuntime();
261
+ const currentCfg = rt.config.loadConfig();
253
262
 
254
- const isGroup = isGroupSession(message.sessionType);
255
- const from = message.sendID;
256
- const rawText = message.content?.text || "";
263
+ const isGroup = isGroupSession(message.sessionType);
264
+ const from = message.sendID;
265
+ const rawText = message.content?.text || "";
257
266
 
258
- log?.info?.(
259
- `[${accountId}] Incoming message: sessionType=${message.sessionType}, isGroup=${isGroup}, ` +
260
- `from=${from}, recvID=${message.recvID}, isAtSelf=${message.isAtSelf ?? false}`,
261
- );
267
+ log?.info?.(
268
+ `[${accountId}] Incoming message: sessionType=${message.sessionType}, isGroup=${isGroup}, ` +
269
+ `from=${from}, recvID=${message.recvID}, isAtSelf=${message.isAtSelf ?? false}`,
270
+ );
262
271
 
263
- const historyKey = isGroup ? `teamily:group:${message.recvID}` : undefined;
264
-
265
- // In group chats, buffer non-@-mention text-only messages for context.
266
- // Media messages (picture/video/audio) are always dispatched — OpenIM sends
267
- // @-mention text and media as separate messages, so a PICTURE following an
268
- // AT_TEXT won't have isAtSelf=true. Dispatching media keeps group image
269
- // handling consistent with DM behavior.
270
- const hasMedia = !!(
271
- message.content?.picture ||
272
- message.content?.video ||
273
- message.content?.audio
274
- );
275
- if (isGroup && !message.isAtSelf && !hasMedia) {
276
- if (historyKey && rawText) {
277
- recordPendingHistoryEntryIfEnabled({
278
- historyMap: groupHistories,
279
- historyKey,
280
- limit: historyLimit,
281
- entry: {
282
- sender: from,
283
- body: rawText,
284
- timestamp: message.sendTime,
285
- },
286
- });
272
+ const historyKey = isGroup ? `teamily:group:${message.recvID}` : undefined;
273
+
274
+ // In group chats, buffer non-@-mention text-only messages for context.
275
+ // Media messages (picture/video/audio) are always dispatched — OpenIM sends
276
+ // @-mention text and media as separate messages, so a PICTURE following an
277
+ // AT_TEXT won't have isAtSelf=true. Dispatching media keeps group image
278
+ // handling consistent with DM behavior.
279
+ const hasMedia = !!(
280
+ message.content?.picture ||
281
+ message.content?.video ||
282
+ message.content?.audio
283
+ );
284
+ if (isGroup && !message.isAtSelf && !hasMedia) {
285
+ if (historyKey && rawText) {
286
+ recordPendingHistoryEntryIfEnabled({
287
+ historyMap: groupHistories,
288
+ historyKey,
289
+ limit: historyLimit,
290
+ entry: {
291
+ sender: from,
292
+ body: rawText,
293
+ timestamp: message.sendTime,
294
+ },
295
+ });
296
+ }
297
+ return;
287
298
  }
288
- return;
289
- }
290
299
 
291
- const sessionKey = isGroup ? `teamily:group:${message.recvID}` : `teamily:${from}`;
300
+ const sessionKey = isGroup ? `teamily:group:${message.recvID}` : `teamily:${from}`;
292
301
 
293
- let mediaUrl: string | undefined;
294
- if (message.content?.picture?.sourcePicture?.url) {
295
- mediaUrl = message.content.picture.sourcePicture.url;
296
- } else if (message.content?.video?.videoUrl) {
297
- mediaUrl = message.content.video.videoUrl;
298
- } else if (message.content?.audio?.sourceUrl) {
299
- mediaUrl = message.content.audio.sourceUrl;
300
- }
302
+ let mediaUrl: string | undefined;
303
+ if (message.content?.picture?.sourcePicture?.url) {
304
+ mediaUrl = message.content.picture.sourcePicture.url;
305
+ } else if (message.content?.video?.videoUrl) {
306
+ mediaUrl = message.content.video.videoUrl;
307
+ } else if (message.content?.audio?.sourceUrl) {
308
+ mediaUrl = message.content.audio.sourceUrl;
309
+ }
301
310
 
302
- // Download remote media to a local temp file so the agent recognises
303
- // image-only messages (hasMediaAttachment checks MediaPath, not MediaUrl).
304
- let mediaPath: string | undefined;
305
- let mediaType: string | undefined;
306
- if (mediaUrl) {
307
- try {
308
- const fetched = await rt.channel.media.fetchRemoteMedia({ url: mediaUrl, maxBytes: MEDIA_MAX_BYTES });
309
- const saved = await rt.channel.media.saveMediaBuffer(
310
- fetched.buffer,
311
- fetched.contentType,
312
- "inbound",
313
- MEDIA_MAX_BYTES,
314
- );
315
- mediaPath = saved.path;
316
- mediaType = saved.contentType;
317
- } catch (err) {
318
- log?.warn?.(`[${accountId}] Failed to download Teamily media: ${String(err)}`);
311
+ // Download remote media to a local temp file so the agent recognises
312
+ // image-only messages (hasMediaAttachment checks MediaPath, not MediaUrl).
313
+ let mediaPath: string | undefined;
314
+ let mediaType: string | undefined;
315
+ if (mediaUrl) {
316
+ try {
317
+ const fetched = await rt.channel.media.fetchRemoteMedia({
318
+ url: mediaUrl,
319
+ maxBytes: MEDIA_MAX_BYTES,
320
+ });
321
+ const saved = await rt.channel.media.saveMediaBuffer(
322
+ fetched.buffer,
323
+ fetched.contentType,
324
+ "inbound",
325
+ MEDIA_MAX_BYTES,
326
+ );
327
+ mediaPath = saved.path;
328
+ mediaType = saved.contentType;
329
+ } catch (err) {
330
+ log?.warn?.(`[${accountId}] Failed to download Teamily media: ${String(err)}`);
331
+ }
319
332
  }
320
- }
321
333
 
322
- // For group @-mention messages, prepend buffered history as context.
323
- const body =
324
- isGroup && historyKey
325
- ? buildPendingHistoryContextFromMap({
326
- historyMap: groupHistories,
327
- historyKey,
328
- limit: historyLimit,
329
- currentMessage: rawText,
330
- formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
331
- })
332
- : rawText;
333
-
334
- const replyTarget = isGroup ? `group:${message.recvID}` : from;
335
- const msgCtx = {
336
- Body: body,
337
- From: from,
338
- To: account.userID,
339
- SessionKey: sessionKey,
340
- AccountId: accountId,
341
- Provider: "teamily" as const,
342
- Surface: "teamily" as const,
343
- OriginatingChannel: "teamily" as const,
344
- OriginatingTo: replyTarget,
345
- ChatType: isGroup ? "group" : "direct",
346
- MediaUrl: mediaUrl,
347
- MediaPath: mediaPath,
348
- MediaType: mediaType,
349
- };
334
+ // For group @-mention messages, prepend buffered history as context.
335
+ const body =
336
+ isGroup && historyKey
337
+ ? buildPendingHistoryContextFromMap({
338
+ historyMap: groupHistories,
339
+ historyKey,
340
+ limit: historyLimit,
341
+ currentMessage: rawText,
342
+ formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
343
+ })
344
+ : rawText;
350
345
 
351
- log?.info?.(
352
- `[${accountId}] Dispatching reply: sessionKey=${sessionKey}, chatType=${isGroup ? "group" : "direct"}, replyTarget=${replyTarget}`,
353
- );
346
+ const replyTarget = isGroup ? `group:${message.recvID}` : from;
347
+ const msgCtx = {
348
+ Body: body,
349
+ From: from,
350
+ To: account.userID,
351
+ SessionKey: sessionKey,
352
+ AccountId: accountId,
353
+ Provider: "teamily" as const,
354
+ Surface: "teamily" as const,
355
+ OriginatingChannel: "teamily" as const,
356
+ OriginatingTo: replyTarget,
357
+ ChatType: isGroup ? "group" : "direct",
358
+ MediaUrl: mediaUrl,
359
+ MediaPath: mediaPath,
360
+ MediaType: mediaType,
361
+ };
362
+
363
+ log?.info?.(
364
+ `[${accountId}] Dispatching reply: sessionKey=${sessionKey}, chatType=${isGroup ? "group" : "direct"}, replyTarget=${replyTarget}`,
365
+ );
354
366
 
355
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
356
- ctx: msgCtx,
357
- cfg: currentCfg,
358
- dispatcherOptions: {
359
- deliver: async (payload: { text?: string; body?: string }) => {
360
- const replyText = payload?.text ?? payload?.body;
361
- if (replyText) {
367
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
368
+ ctx: msgCtx,
369
+ cfg: currentCfg,
370
+ dispatcherOptions: {
371
+ deliver: async (payload) => {
362
372
  const monitor = getTeamilyMonitor(accountId);
363
- if (!monitor) throw new Error(`Teamily monitor not running for account ${accountId}`);
364
- log?.info?.(`[${accountId}] Sending reply to: ${replyTarget} (isGroup=${isGroup})`);
373
+ if (!monitor)
374
+ throw new Error(`Teamily monitor not running for account ${accountId}`);
365
375
  const target = normalizeTeamilyTarget(replyTarget);
366
- await monitor.sendText(target, replyText);
367
- }
368
- },
369
- onReplyStart: () => {
370
- log?.info?.(`Agent reply started for ${from}`);
371
- },
372
- },
373
- });
376
+ const replyText = (payload as Record<string, unknown>)?.text as string | undefined
377
+ ?? (payload as Record<string, unknown>)?.body as string | undefined
378
+ ?? "";
374
379
 
375
- log?.info?.(`[${accountId}] Dispatch completed for ${sessionKey}`);
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
+ });
376
396
 
377
- // Clear history buffer after dispatch so context doesn't repeat.
378
- if (isGroup && historyKey) {
379
- clearHistoryEntriesIfEnabled({
380
- historyMap: groupHistories,
381
- historyKey,
382
- limit: historyLimit,
397
+ // If no media was sent, send text only.
398
+ if (!sentMedia && replyText) {
399
+ log?.info?.(
400
+ `[${accountId}] Sending reply to: ${replyTarget} (isGroup=${isGroup})`,
401
+ );
402
+ await monitor.sendText(target, replyText);
403
+ }
404
+ },
405
+ onReplyStart: () => {
406
+ log?.info?.(`Agent reply started for ${from}`);
407
+ },
408
+ },
383
409
  });
384
- }
410
+
411
+ log?.info?.(`[${accountId}] Dispatch completed for ${sessionKey}`);
412
+
413
+ // Clear history buffer after dispatch so context doesn't repeat.
414
+ if (isGroup && historyKey) {
415
+ clearHistoryEntriesIfEnabled({
416
+ historyMap: groupHistories,
417
+ historyKey,
418
+ limit: historyLimit,
419
+ });
420
+ }
385
421
  } catch (err) {
386
422
  log?.error?.(
387
423
  `[${accountId}] Error handling message from ${message.sendID}: ${err instanceof Error ? err.stack || err.message : String(err)}`,
@@ -410,7 +446,7 @@ function applyTeamilyAccountConfig(params: {
410
446
  }): CoreConfig {
411
447
  const { cfg, accountId, input } = params;
412
448
  const existing = cfg.channels?.teamily;
413
- const server = existing?.server ?? { platformUrl: "", apiURL: "", wsURL: "" };
449
+ const server = existing?.server ?? { apiURL: "", wsURL: "" };
414
450
  const accounts = existing?.accounts ?? {};
415
451
 
416
452
  const accountUpdate: Record<string, unknown> = {};
@@ -420,7 +456,6 @@ function applyTeamilyAccountConfig(params: {
420
456
  if (input.faceURL) accountUpdate.faceURL = String(input.faceURL);
421
457
 
422
458
  const serverUpdate: Record<string, string> = {};
423
- if (input.platformUrl) serverUpdate.platformUrl = String(input.platformUrl);
424
459
  if (input.apiURL) serverUpdate.apiURL = String(input.apiURL);
425
460
  if (input.wsURL) serverUpdate.wsURL = String(input.wsURL);
426
461
 
@@ -443,11 +478,73 @@ function applyTeamilyAccountConfig(params: {
443
478
  } as CoreConfig;
444
479
  }
445
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
+
446
541
  function requireMonitor(accountId?: string | null) {
447
542
  const id = accountId || "default";
448
543
  const monitor = getTeamilyMonitor(id);
449
544
  if (!monitor) {
450
- throw new Error(`Teamily gateway not running for account "${id}" — outbound requires an active gateway`);
545
+ throw new Error(
546
+ `Teamily gateway not running for account "${id}" — outbound requires an active gateway`,
547
+ );
451
548
  }
452
549
  return monitor;
453
550
  }
@@ -5,7 +5,6 @@ import type { TeamilyConfig } from "./types.js";
5
5
 
6
6
  // Server configuration schema
7
7
  export const TeamilyServerConfigSchema = z.object({
8
- platformUrl: z.string().url().default("http://localhost:10002").describe("Teamily platform URL"),
9
8
  apiURL: z.string().url().default("http://localhost:10002").describe("Teamily REST API URL"),
10
9
  wsURL: z.string().url().default("ws://localhost:10001").describe("Teamily WebSocket URL"),
11
10
  });
package/src/monitor.ts CHANGED
@@ -205,6 +205,99 @@ export class TeamilyMonitor {
205
205
  return result.data?.serverMsgID || result.data?.clientMsgID || "";
206
206
  }
207
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
+
208
301
  private requireSdk(): SdkInstance {
209
302
  if (!this.sdk) {
210
303
  throw new Error("Teamily SDK not connected");
package/src/types.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  // Core configuration types for Teamily channel
2
2
 
3
3
  export interface TeamilyServerConfig {
4
- platformUrl: string;
5
4
  apiURL: string;
6
5
  wsURL: string;
7
6
  }
@@ -29,7 +28,6 @@ export interface TeamilyConfig {
29
28
  export interface ResolvedTeamilyAccount {
30
29
  accountId: string;
31
30
  enabled: boolean;
32
- platformUrl: string;
33
31
  apiURL: string;
34
32
  wsURL: string;
35
33
  userID: string;
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
+ }